Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 6 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,12 @@ const program = Effect.gen(function* () {
}
if (
process.env.T3_ACP_CRASH_PROMPT === "1" &&
request.prompt.some((part) => part.type === "text" && part.text === "crash now")
request.prompt.some(
(part) =>
part.type === "text" &&
typeof part.text === "string" &&
part.text.includes("crash now"),
)
) {
return yield* Effect.sync(() => process.exit(23));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 @@ -126,6 +128,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 client file system", () => {
it.effect("confines agent file requests to the workspace under full access", () =>
Effect.gen(function* () {
Expand Down
27 changes: 27 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,33 @@ describe("AcpSessionRuntime", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect.each([
{
name: "a final diagnostic",
suffix: "\nFinal synthetic crash diagnostic.",
expected: "Final synthetic crash diagnostic.",
},
{ name: "no complete line", suffix: "", expected: undefined },
])("omits a credential cut by the stderr tail boundary with $name", (testCase) =>
Effect.gen(function* () {
const stderr = `api_key=${"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
15 changes: 13 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 @@ -1298,7 +1302,14 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* (
Effect.ignore,
Effect.andThen(Ref.get(stderrTailRef)),
Effect.map((tail) => {
const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory);
// A clipped first line can contain a credential whose identifying prefix was discarded.
const completeTail =
tail.length < ACP_STDERR_TAIL_MAX_CHARS
? tail
: tail.includes("\n")
? tail.slice(tail.indexOf("\n") + 1)
: "";
const stderr = sanitizeAcpStderrExcerpt(completeTail, 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 @@ -67,6 +67,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("drops transient ACP chunks before formatting verbose protocol logs", () =>
Effect.gen(function* () {
const records: Array<unknown> = [];
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 @@ -6,6 +6,8 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import type * as EffectAcpProtocol from "effect-acp/protocol";

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

import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import {
structuralProtocolMethod,
Expand All @@ -25,6 +27,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
9 changes: 9 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,15 @@ describe("AcpStderr", () => {
expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv");
});

it("redacts complete API keys whose synthetic bodies contain underscores", () => {
const excerpt = sanitizeAcpStderrExcerpt(
"openai=sk-proj-fixture_abc_def_0123456789\nanthropic=sk-ant-api03-fixture_abc_def_0123456789-AA",
"fixture-home",
{},
);
expect(excerpt).toBe("openai=[redacted]\nanthropic=[redacted]");
});

it("redacts hyphenated OpenAI project keys and header credentials", () => {
const excerpt = sanitizeAcpStderrExcerpt(
[
Expand Down
4 changes: 2 additions & 2 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,}|ghp_[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 Down
Loading
Loading