Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,17 @@ const program = Effect.gen(function* () {
);
return {};
}
if (
process.env.T3_ACP_CRASH_PROMPT === "1" &&
request.prompt.some(
(part) =>
part.type === "text" &&
typeof part.text === "string" &&
part.text.includes("crash now"),
)
) {
return yield* Effect.sync(() => process.exit(23));
}

if (clientFsProbePath !== undefined && clientFsProbeLogPath !== undefined) {
const probes = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
makeAntigravityAdapterV2,
} from "./AntigravityAdapterV2.ts";

import { makeTurnInput } from "./AcpAdapterV2.testkit.ts";

const flavor = makeAntigravityAcpAdapterFlavor({
instanceId: ProviderInstanceId.make("antigravity-test"),
crypto: undefined as never,
Expand Down Expand Up @@ -128,6 +130,88 @@ const layerSession = Layer.mergeAll(
TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
);

describe("AntigravityAdapterV2 process failures", () => {
it.effect("keeps a crashed child's exit code and redacted stderr in the run failure", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const crypto = yield* Crypto.Crypto;
const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "acp-exit-test-" });
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const instanceId = ProviderInstanceId.make("antigravity-exit-test");
const threadId = ThreadId.make("thread-antigravity-exit-test");
const adapter = yield* makeAntigravityAdapterV2({
instanceId,
selfInvocation: yield* resolveSelfInvocation(),
makeRuntime: (input) =>
makeAntigravityAcpRuntime({
...input,
childProcessSpawner,
spawn: {
command: process.execPath,
args: [
"-e",
`process.stderr.write("Synthetic configuration failed.\\nAuthorization: Bearer fixture-only-token\\n"); import(${JSON.stringify(mockAgentPath)});`,
],
cwd: workspace,
extendEnv: false,
env: {
HOME: workspace,
USERPROFILE: workspace,
T3_ACP_ANTIGRAVITY: "1",
T3_ACP_CRASH_PROMPT: "1",
},
},
}).pipe(Effect.provideService(Crypto.Crypto, crypto)),
withProcess: (_stop, task) => task,
// @effect-diagnostics-next-line effectSucceedWithVoid:off - The optional model result is undefined.
defaultModel: Effect.succeed(undefined),
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: workspace,
});
const modelSelection = { instanceId, model: "gemini-test-low" } as const;
const session = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-antigravity-exit-test"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* session.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
yield* session.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
modelSelection,
now: yield* DateTime.now,
messageText: "crash now",
}),
);
const events = yield* session.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
);
const terminal = Array.from(events).find((event) => event.type === "turn.terminal");
assert.equal(terminal?.status, "failed");
assert.include(terminal?.failure?.message ?? "", "ACP process exited with code 23");
assert.include(terminal?.failure?.message ?? "", "Synthetic configuration failed.");
assert.notInclude(terminal?.failure?.message ?? "", "fixture-only-token");
assert.equal(terminal?.failure?.code, "23");
}).pipe(Effect.provide(layerSession), Effect.scoped),
);
});

describe("AntigravityAdapterV2 session open", () => {
it.effect.each([
{
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,40 @@ describe("AcpSessionRuntime", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect.each([
{
name: "a final diagnostic",
prefix: "api_key=",
suffix: "\nFinal synthetic crash diagnostic.",
expected: undefined,
},
{ name: "no complete line", prefix: "api_key=", suffix: "", expected: undefined },
{
name: "a clipped private-key header",
prefix: "-----BEGIN PRIVATE KEY-----\n",
suffix: "\n-----END PRIVATE KEY-----",
expected: undefined,
},
])("omits a credential cut by the stderr tail boundary with $name", (testCase) =>
Effect.gen(function* () {
const stderr = `${testCase.prefix}${"fixturecredential".repeat(600)}${testCase.suffix}`;
const runtime = yield* AcpSessionRuntime.make({
...mockRuntimeOptions,
spawn: {
command: process.execPath,
args: ["-e", `process.stderr.write(${JSON.stringify(stderr)}, () => process.exit(23));`],
extendEnv: false,
env: {},
},
});
const error = yield* runtime.start().pipe(Effect.flip);
assert.instanceOf(error, EffectAcpErrors.AcpProcessExitedError);
assert.equal(error.code, 23);
assert.isFalse(error.stderr?.includes("fixturecredential") ?? false);
assert.equal(error.stderr, testCase.expected);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>
Effect.gen(function* () {
const lengths: Array<number> = [];
Expand Down
5 changes: 4 additions & 1 deletion docs/internals/effect-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ optional-service layer. Don't route around the layer with an imperative runtime.
lives only in `cause`. Expose a category, length, count, or a URL's protocol and host instead.
`DeviceBootError.diagnostics` is a deliberate exception: authorized device users and agents
need the device hub's launch command and failure text to diagnose host setup. It is capped at
2,000 characters and included in the error message; other service errors keep the rule above.
2,000 characters and included in the error message. ACP process-exit failures may include a
sanitized stderr excerpt within the 4,096-character failure-message cap: credential-labelled
lines and private-key bodies are withheld, and capped raw tails are omitted because a
credential header may have been clipped. Other service errors keep the rule above.
- **Translation.** Construct the error where the failure happens, and map it to a transport error
only in the transport. A translation boundary passes through domain errors already in the target
channel and wraps only unknown or lower-level failures. Map each failure where its context is
Expand Down
12 changes: 10 additions & 2 deletions packages/provider-acp/src/server/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import { AgentScope, AgentScopeThreadId, RAISE_OOM_SCORE_LINE } from "@t3tools/s
import * as HostProcess from "@t3tools/shared/HostProcess";

import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts";
import {
ACP_STDERR_TAIL_MAX_CHARS,
appendAcpStderrTail,
sanitizeAcpStderrExcerpt,
} from "./stderr.ts";
import {
collectSessionConfigOptionValues,
decideToolCallUpdateEmission,
Expand Down Expand Up @@ -1294,7 +1298,11 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
Effect.ignore,
Effect.andThen(Ref.get(stderrTailRef)),
Effect.map((tail) => {
const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory);
// A capped tail can start inside a multiline credential whose header was discarded.
const stderr =
tail.length < ACP_STDERR_TAIL_MAX_CHARS
? sanitizeAcpStderrExcerpt(tail, homeDirectory)
: "";
return stderr.length === 0
? error
: new EffectAcpErrors.AcpProcessExitedError({
Expand Down
36 changes: 36 additions & 0 deletions packages/provider-acp/src/server/nativeLogging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ nodeServicesIt("ACP native logging", (it) => {
}),
);

it.effect("records the sanitized exit diagnostic for a crashed ACP request", () =>
Effect.gen(function* () {
const records: Array<unknown> = [];
const makeLogger = yield* makeAcpNativeLoggerFactory();
const logger = makeLogger({
nativeEventLogger: {
filePath: "synthetic-native.ndjson",
write: (event) => Effect.sync(() => void records.push(event)),
close: () => Effect.void,
},
provider: ProviderDriverKind.make("antigravity"),
threadId: ThreadId.make("synthetic-crash-thread"),
});
const requestLogger = logger.requestLogger;
assert.exists(requestLogger);
if (!requestLogger) return;
yield* requestLogger({
method: "session/prompt",
payload: { prompt: "synthetic prompt" },
status: "failed",
cause: Cause.fail(
new AcpErrors.AcpProcessExitedError({
code: 23,
stderr: "Synthetic configuration failed.\napi_key=fixture-only-key",
}),
),
});
const serialized = encodeUnknownJson(records);
assert.include(serialized, "ACP process exited with code 23");
assert.include(serialized, "Synthetic configuration failed.");
assert.include(serialized, '"code":"23"');
assert.notInclude(serialized, "fixture-only-key");
assert.notInclude(serialized, "synthetic prompt");
}),
);

it.effect("preserves native writer interruption", () =>
Effect.gen(function* () {
const makeLogger = yield* makeAcpNativeLoggerFactory();
Expand Down
5 changes: 5 additions & 0 deletions packages/provider-acp/src/server/nativeLogging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";

import { makeProviderFailure } from "@t3tools/provider-core/server/failure";

import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import {
structuralProtocolMethod,
Expand All @@ -22,6 +24,9 @@ function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEv
? {
errorTag: causeErrorTag(event.cause),
reasonCount: event.cause.reasons.length,
...(causeErrorTag(event.cause) === "AcpProcessExitedError"
? { failure: makeProviderFailure({ cause: event.cause, class: "provider_error" }) }
: {}),
}
: {}),
};
Expand Down
15 changes: 15 additions & 0 deletions packages/provider-acp/src/server/stderr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ describe("AcpStderr", () => {
expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv");
});

it("withholds multiline private-key bodies, including unterminated keys", () => {
for (const end of ["-----END PRIVATE KEY-----", ""]) {
const excerpt = sanitizeAcpStderrExcerpt(
`Configuration failed.\n-----BEGIN PRIVATE KEY-----\nZml4dHVyZS1wcml2YXRlLWJvZHk=\n${end}`,
"/home/server",
{},
);
expect(excerpt).toContain("Configuration failed.");
expect(excerpt).not.toContain("Zml4dHVyZS1wcml2YXRlLWJvZHk=");
}
});

it("redacts hyphenated OpenAI project keys and header credentials", () => {
const excerpt = sanitizeAcpStderrExcerpt(
[
Expand All @@ -35,6 +47,7 @@ describe("AcpStderr", () => {
"anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345",
"Authorization: Basic dXNlcjpwYXNz",
"x-api-key: ant-api-key-value",
"github_pat_fixture_123 gho_fixture123 ghu_fixture123 ghs_fixture123 ghr_fixture123",
].join("\n"),
"/Users/ada",
{},
Expand All @@ -49,6 +62,8 @@ describe("AcpStderr", () => {
expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345");
expect(excerpt).not.toContain("dXNlcjpwYXNz");
expect(excerpt).not.toContain("ant-api-key-value");
expect(excerpt).not.toContain("fixture123");
expect(excerpt).not.toContain("fixture_123");
});

it("redacts the whole key when its body contains underscores", () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/provider-acp/src/server/stderr.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const ACP_STDERR_TAIL_MAX_CHARS = 4_096;
export const ACP_STDERR_TAIL_MAX_CHARS = 4_096;

const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi;
const SIGN_IN_URL_PATTERN = /https?:\/\/accounts\.google\.com[^\s]*/gi;
const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi;
const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi;
const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi;
const SECRET_TOKEN_PATTERN =
/\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g;
/\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g;

export function appendAcpStderrTail(current: string, chunk: string): string {
const next = `${current}${chunk}`;
Expand All @@ -18,7 +18,9 @@ export function sanitizeAcpStderrExcerpt(
homeDirectory: string,
environment: NodeJS.ProcessEnv = process.env,
): string {
let result = text.replaceAll("\0", "");
let result = text
.replaceAll("\0", "")
.replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*/giu, "[redacted private key]");
const homes = [environment.HOME, environment.USERPROFILE, homeDirectory].filter(
(value): value is string => typeof value === "string" && value.length > 1,
);
Expand Down
Loading
Loading