| author | |
| committer | |
| log | d6f291303ddc0c9a14f570266a4d9eff6d431593 |
| tree | 90d115e4249a741d352016718c1a0526d4e5b2b7 |
| parent | 52d08a14b9e475371658fabbb12c056c3d3dad67 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>9 files changed, 242 insertions(+), 11 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5| ... | @@ -36,6 +36,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ... | @@ -36,6 +36,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 36 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 36 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 37 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; | 37 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; |
| 38 | import { | 38 | import { |
| 39 | ProviderAdapterProcessError, | ||
| 39 | ProviderAdapterRequestError, | 40 | ProviderAdapterRequestError, |
| 40 | ProviderAdapterValidationError, | 41 | ProviderAdapterValidationError, |
| 41 | ProviderWorkspaceMissingError, | 42 | ProviderWorkspaceMissingError, |
| ... | @@ -64,6 +65,7 @@ import { | ... | @@ -64,6 +65,7 @@ import { |
| 64 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 65 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 65 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 66 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 66 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; | 67 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 68 | const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); | ||
| 67 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); | 69 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 68 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); | 70 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); |
| 69 | const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); | 71 | const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); |
| ... | @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { | ... | @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { |
| 373 | if (isProviderAdapterRequestError(failReason?.error)) { | 375 | if (isProviderAdapterRequestError(failReason?.error)) { |
| 374 | return failReason.error.detail; | 376 | return failReason.error.detail; |
| 375 | } | 377 | } |
| 378 | if (isProviderAdapterProcessError(failReason?.error)) { | ||
| 379 | return failReason.error.detail; | ||
| 380 | } | ||
| 376 | if (isProviderAdapterValidationError(failReason?.error)) { | 381 | if (isProviderAdapterValidationError(failReason?.error)) { |
| 377 | return failReason.error.issue; | 382 | return failReason.error.issue; |
| 378 | } | 383 | } |
apps/server/src/provider/Layers/CursorAdapter.test.ts+43| ... | @@ -543,6 +543,49 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -543,6 +543,49 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 543 | }), | 543 | }), |
| 544 | ); | 544 | ); |
| 545 | 545 | ||
| 546 | it.effect("surfaces cursor-agent cli.json schema stderr instead of a closed-session error", () => | ||
| 547 | Effect.gen(function* () { | ||
| 548 | const adapter = yield* CursorAdapter; | ||
| 549 | const settings = yield* ServerSettingsService; | ||
| 550 | const workspace = yield* Effect.promise(() => | ||
| 551 | NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-cli-json-")), | ||
| 552 | ); | ||
| 553 | const wrapperPath = writeFakeCli({ | ||
| 554 | directory: workspace, | ||
| 555 | name: "fake-cursor-agent", | ||
| 556 | source: [ | ||
| 557 | "process.stderr.write(`Invalid project config at ${process.cwd()}/.cursor/cli.json: schema validation failed. [`", | ||
| 558 | " + JSON.stringify({", | ||
| 559 | ' code: "unrecognized_keys",', | ||
| 560 | ' keys: ["approvalMode", "sandbox"],', | ||
| 561 | " path: [],", | ||
| 562 | " message: \"Unrecognized key(s) in object: 'approvalMode', 'sandbox'\",", | ||
| 563 | ' }) + "]\\n");', | ||
| 564 | "process.exit(1);", | ||
| 565 | ].join("\n"), | ||
| 566 | }); | ||
| 567 | yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); | ||
| 568 | |||
| 569 | const error = yield* adapter | ||
| 570 | .startSession({ | ||
| 571 | threadId: ThreadId.make("cursor-cli-json-schema"), | ||
| 572 | provider: ProviderDriverKind.make("cursor"), | ||
| 573 | cwd: workspace, | ||
| 574 | runtimeMode: "full-access", | ||
| 575 | }) | ||
| 576 | .pipe(Effect.flip); | ||
| 577 | |||
| 578 | assert.equal(error._tag, "ProviderAdapterProcessError"); | ||
| 579 | assert.include(error.message, "cli.json"); | ||
| 580 | assert.include(error.message, "Unrecognized key"); | ||
| 581 | assert.notInclude(error.message, "adapter thread is closed"); | ||
| 582 | if (error._tag === "ProviderAdapterProcessError") { | ||
| 583 | assert.include(error.detail, "approvalMode"); | ||
| 584 | assert.include(error.detail, "sandbox"); | ||
| 585 | } | ||
| 586 | }), | ||
| 587 | ); | ||
| 588 | |||
| 546 | it.effect("maps app plan mode onto the ACP plan session mode", () => | 589 | it.effect("maps app plan mode onto the ACP plan session mode", () => |
| 547 | Effect.gen(function* () { | 590 | Effect.gen(function* () { |
| 548 | const adapter = yield* CursorAdapter; | 591 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+36| ... | @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { | ... | @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { |
| 25 | expect(error._tag).toBe("ProviderAdapterRequestError"); | 25 | expect(error._tag).toBe("ProviderAdapterRequestError"); |
| 26 | expect(error.message).toContain("Invalid params"); | 26 | expect(error.message).toContain("Invalid params"); |
| 27 | }); | 27 | }); |
| 28 | |||
| 29 | it("maps ACP process exits without stderr to a process error instead of a closed session", () => { | ||
| 30 | const error = mapAcpToAdapterError( | ||
| 31 | ProviderDriverKind.make("cursor"), | ||
| 32 | "thread-1" as never, | ||
| 33 | "session/start", | ||
| 34 | new EffectAcpErrors.AcpProcessExitedError({ code: 1 }), | ||
| 35 | ); | ||
| 36 | |||
| 37 | expect(error._tag).toBe("ProviderAdapterProcessError"); | ||
| 38 | expect(error.message).not.toContain("adapter thread is closed"); | ||
| 39 | if (error._tag === "ProviderAdapterProcessError") { | ||
| 40 | expect(error.detail).toBe("ACP process exited with code 1"); | ||
| 41 | } | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("maps ACP process exits to a process error whose detail includes stderr", () => { | ||
| 45 | const error = mapAcpToAdapterError( | ||
| 46 | ProviderDriverKind.make("cursor"), | ||
| 47 | "thread-1" as never, | ||
| 48 | "session/start", | ||
| 49 | new EffectAcpErrors.AcpProcessExitedError({ | ||
| 50 | code: 1, | ||
| 51 | stderr: | ||
| 52 | "Invalid project config at ~/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'", | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | |||
| 56 | expect(error._tag).toBe("ProviderAdapterProcessError"); | ||
| 57 | expect(error.message).toContain("cli.json"); | ||
| 58 | expect(error.message).toContain("Unrecognized key"); | ||
| 59 | expect(error.message).not.toContain("adapter thread is closed"); | ||
| 60 | if (error._tag === "ProviderAdapterProcessError") { | ||
| 61 | expect(error.detail).toContain("Unrecognized key(s): 'approvalMode', 'sandbox'"); | ||
| 62 | } | ||
| 63 | }); | ||
| 28 | }); | 64 | }); |
apps/server/src/provider/acp/AcpAdapterSupport.ts+3-2| ... | @@ -7,8 +7,8 @@ import * as Schema from "effect/Schema"; | ... | @@ -7,8 +7,8 @@ import * as Schema from "effect/Schema"; |
| 7 | import * as EffectAcpErrors from "effect-acp/errors"; | 7 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | ProviderAdapterProcessError, | ||
| 10 | ProviderAdapterRequestError, | 11 | ProviderAdapterRequestError, |
| 11 | ProviderAdapterSessionClosedError, | ||
| 12 | type ProviderAdapterError, | 12 | type ProviderAdapterError, |
| 13 | } from "../Errors.ts"; | 13 | } from "../Errors.ts"; |
| 14 | const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); | 14 | const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); |
| ... | @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( | ... | @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( |
| 21 | error: EffectAcpErrors.AcpError, | 21 | error: EffectAcpErrors.AcpError, |
| 22 | ): ProviderAdapterError { | 22 | ): ProviderAdapterError { |
| 23 | if (isAcpProcessExitedError(error)) { | 23 | if (isAcpProcessExitedError(error)) { |
| 24 | return new ProviderAdapterSessionClosedError({ | 24 | return new ProviderAdapterProcessError({ |
| 25 | provider, | 25 | provider, |
| 26 | threadId, | 26 | threadId, |
| 27 | detail: error.message, | ||
| 27 | cause: error, | 28 | cause: error, |
| 28 | }); | 29 | }); |
| 29 | } | 30 | } |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+20| ... | @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { | ... | @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { |
| 368 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 368 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 369 | ); | 369 | ); |
| 370 | 370 | ||
| 371 | it.effect("attaches child stderr when the ACP process exits before initialize", () => | ||
| 372 | Effect.gen(function* () { | ||
| 373 | const runtime = yield* AcpSessionRuntime.make({ | ||
| 374 | ...mockRuntimeOptions, | ||
| 375 | spawn: { | ||
| 376 | command: process.execPath, | ||
| 377 | args: [ | ||
| 378 | "-e", | ||
| 379 | "process.stderr.write(\"Invalid project config at /tmp/project/.cursor/cli.json: schema validation failed. Unrecognized key(s): 'approvalMode', 'sandbox'\\n\"); process.exit(1);", | ||
| 380 | ], | ||
| 381 | }, | ||
| 382 | }); | ||
| 383 | const error = yield* runtime.start().pipe(Effect.flip); | ||
| 384 | expect(error._tag).toBe("AcpProcessExitedError"); | ||
| 385 | expect(error.message).toContain("cli.json"); | ||
| 386 | expect(error.message).toContain("Unrecognized key"); | ||
| 387 | expect(error.message).not.toContain("ACP process exited with code 1\nACP process exited"); | ||
| 388 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 389 | ); | ||
| 390 | |||
| 371 | it.effect("drains large stderr output and keeps auth-sized logging chunks", () => | 391 | it.effect("drains large stderr output and keeps auth-sized logging chunks", () => |
| 372 | Effect.gen(function* () { | 392 | Effect.gen(function* () { |
| 373 | const lengths: Array<number> = []; | 393 | const lengths: Array<number> = []; |
apps/server/src/provider/acp/AcpSessionRuntime.ts+36-6| ... | @@ -22,6 +22,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; | ... | @@ -22,6 +22,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 22 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 22 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 24 | 24 | ||
| 25 | import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; | ||
| 25 | import { | 26 | import { |
| 26 | collectSessionConfigOptionValues, | 27 | collectSessionConfigOptionValues, |
| 27 | decideToolCallUpdateEmission, | 28 | decideToolCallUpdateEmission, |
| ... | @@ -354,6 +355,8 @@ export const make = ( | ... | @@ -354,6 +355,8 @@ export const make = ( |
| 354 | ); | 355 | ); |
| 355 | const stoppingRef = yield* Ref.make(false); | 356 | const stoppingRef = yield* Ref.make(false); |
| 356 | const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>(); | 357 | const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>(); |
| 358 | const stderrTailRef = yield* Ref.make(""); | ||
| 359 | const stderrDrained = yield* Deferred.make<void>(); | ||
| 357 | const runtimeClosed = yield* Deferred.make<void>(); | 360 | const runtimeClosed = yield* Deferred.make<void>(); |
| 358 | const promptSerializationSemaphore = yield* Semaphore.make(1); | 361 | const promptSerializationSemaphore = yield* Semaphore.make(1); |
| 359 | const promptDispatchSemaphore = yield* Semaphore.make(1); | 362 | const promptDispatchSemaphore = yield* Semaphore.make(1); |
| ... | @@ -374,22 +377,45 @@ export const make = ( | ... | @@ -374,22 +377,45 @@ export const make = ( |
| 374 | } | 377 | } |
| 375 | }); | 378 | }); |
| 376 | 379 | ||
| 380 | const enrichProcessExitWithStderr = ( | ||
| 381 | error: EffectAcpErrors.AcpError, | ||
| 382 | ): Effect.Effect<EffectAcpErrors.AcpError> => | ||
| 383 | error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 | ||
| 384 | ? Effect.succeed(error) | ||
| 385 | : Deferred.await(stderrDrained).pipe( | ||
| 386 | Effect.timeout("250 millis"), | ||
| 387 | Effect.ignore, | ||
| 388 | Effect.andThen(Ref.get(stderrTailRef)), | ||
| 389 | Effect.map((tail) => { | ||
| 390 | const stderr = sanitizeAcpStderrExcerpt(tail); | ||
| 391 | return stderr.length === 0 | ||
| 392 | ? error | ||
| 393 | : new EffectAcpErrors.AcpProcessExitedError({ | ||
| 394 | ...(error.code !== undefined ? { code: error.code } : {}), | ||
| 395 | ...(error.pid !== undefined ? { pid: error.pid } : {}), | ||
| 396 | stderr, | ||
| 397 | ...(error.cause !== undefined ? { cause: error.cause } : {}), | ||
| 398 | }); | ||
| 399 | }), | ||
| 400 | ); | ||
| 401 | |||
| 377 | const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( | 402 | const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( |
| 378 | error: EffectAcpErrors.AcpError, | 403 | error: EffectAcpErrors.AcpError, |
| 379 | ) { | 404 | ) { |
| 380 | if (yield* Ref.get(stoppingRef)) { | 405 | if (yield* Ref.get(stoppingRef)) { |
| 381 | return; | 406 | return; |
| 382 | } | 407 | } |
| 408 | const enriched = yield* enrichProcessExitWithStderr(error); | ||
| 383 | const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => | 409 | const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => |
| 384 | Option.isSome(current) | 410 | Option.isSome(current) |
| 385 | ? ([false, current] as const) | 411 | ? ([false, current] as const) |
| 386 | : ([true, Option.some(error)] as const), | 412 | : ([true, Option.some(enriched)] as const), |
| 387 | ); | 413 | ); |
| 388 | if (!firstTermination) { | 414 | if (!firstTermination) { |
| 389 | return; | 415 | return; |
| 390 | } | 416 | } |
| 391 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); | 417 | yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); |
| 392 | yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error }); | 418 | yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); |
| 393 | }); | 419 | }); |
| 394 | 420 | ||
| 395 | const logRequest = (event: AcpSessionRequestLogEvent) => | 421 | const logRequest = (event: AcpSessionRequestLogEvent) => |
| ... | @@ -406,6 +432,9 @@ export const make = ( | ... | @@ -406,6 +432,9 @@ export const make = ( |
| 406 | ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) | 432 | ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) |
| 407 | : effect | 433 | : effect |
| 408 | ).pipe( | 434 | ).pipe( |
| 435 | Effect.catch((error) => | ||
| 436 | enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), | ||
| 437 | ), | ||
| 409 | Effect.tap((result) => | 438 | Effect.tap((result) => |
| 410 | logRequest({ | 439 | logRequest({ |
| 411 | method, | 440 | method, |
| ... | @@ -453,10 +482,10 @@ export const make = ( | ... | @@ -453,10 +482,10 @@ export const make = ( |
| 453 | yield* child.stderr.pipe( | 482 | yield* child.stderr.pipe( |
| 454 | Stream.decodeText(), | 483 | Stream.decodeText(), |
| 455 | Stream.runForEach((chunk) => | 484 | Stream.runForEach((chunk) => |
| 456 | (options.onStderr | 485 | Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( |
| 457 | ? options.onStderr(chunk.slice(-maxStderrChunkLength)) | 486 | Effect.andThen( |
| 458 | : Effect.void | 487 | options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, |
| 459 | ).pipe( | 488 | ), |
| 460 | Effect.catch((error) => | 489 | Effect.catch((error) => |
| 461 | Effect.gen(function* () { | 490 | Effect.gen(function* () { |
| 462 | yield* Deferred.fail(stderrFailure, error); | 491 | yield* Deferred.fail(stderrFailure, error); |
| ... | @@ -466,6 +495,7 @@ export const make = ( | ... | @@ -466,6 +495,7 @@ export const make = ( |
| 466 | ), | 495 | ), |
| 467 | ), | 496 | ), |
| 468 | ), | 497 | ), |
| 498 | Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), | ||
| 469 | Effect.ignore, | 499 | Effect.ignore, |
| 470 | Effect.forkIn(runtimeScope), | 500 | Effect.forkIn(runtimeScope), |
| 471 | ); | 501 | ); |
apps/server/src/provider/acp/AcpStderr.test.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ACP_STDERR_TAIL_MAX_CHARS, | ||
| 5 | appendAcpStderrTail, | ||
| 6 | sanitizeAcpStderrExcerpt, | ||
| 7 | } from "./AcpStderr.ts"; | ||
| 8 | |||
| 9 | describe("AcpStderr", () => { | ||
| 10 | it("keeps a bounded tail of stderr chunks", () => { | ||
| 11 | const prefix = "x".repeat(ACP_STDERR_TAIL_MAX_CHARS); | ||
| 12 | expect(appendAcpStderrTail(prefix, "abc")).toBe(`${prefix.slice(3)}abc`); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("redacts home paths, pairing URLs, and tokens from stderr excerpts", () => { | ||
| 16 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 17 | [ | ||
| 18 | "Invalid project config at /Users/ada/.cursor/cli.json", | ||
| 19 | "Authorization: Bearer secret-token-value", | ||
| 20 | "Visit http://localhost:5733/pair#token=ABCDEF for pairing", | ||
| 21 | "key=sk-abcdefghijklmnopqrstuv", | ||
| 22 | ].join("\n"), | ||
| 23 | { HOME: "/Users/ada" }, | ||
| 24 | ); | ||
| 25 | |||
| 26 | expect(excerpt).toContain("Invalid project config at ~/.cursor/cli.json"); | ||
| 27 | expect(excerpt).toContain("Bearer [redacted]"); | ||
| 28 | expect(excerpt).toContain("[pairing-url]"); | ||
| 29 | expect(excerpt).toContain("[redacted]"); | ||
| 30 | expect(excerpt).not.toContain("secret-token-value"); | ||
| 31 | expect(excerpt).not.toContain("ABCDEF"); | ||
| 32 | expect(excerpt).not.toContain("sk-abcdefghijklmnopqrstuv"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("redacts hyphenated OpenAI project keys and header credentials", () => { | ||
| 36 | const excerpt = sanitizeAcpStderrExcerpt( | ||
| 37 | [ | ||
| 38 | "openai=sk-proj-abcdefghijklmnopqrstuvwxyz012345", | ||
| 39 | "svc=sk-svcacct-abcdefghijklmnopqrstuvwxyz012345", | ||
| 40 | "anthropic=sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345", | ||
| 41 | "Authorization: Basic dXNlcjpwYXNz", | ||
| 42 | "x-api-key: ant-api-key-value", | ||
| 43 | ].join("\n"), | ||
| 44 | ); | ||
| 45 | |||
| 46 | expect(excerpt).toContain("[redacted]"); | ||
| 47 | expect(excerpt).toContain("Authorization: Basic [redacted]"); | ||
| 48 | expect(excerpt).toContain("x-api-key: [redacted]"); | ||
| 49 | expect(excerpt).not.toContain("sk-proj-"); | ||
| 50 | expect(excerpt).not.toContain("sk-svcacct-"); | ||
| 51 | expect(excerpt).not.toContain("sk-ant-api03-"); | ||
| 52 | expect(excerpt).not.toContain("abcdefghijklmnopqrstuvwxyz012345"); | ||
| 53 | expect(excerpt).not.toContain("dXNlcjpwYXNz"); | ||
| 54 | expect(excerpt).not.toContain("ant-api-key-value"); | ||
| 55 | }); | ||
| 56 | }); | ||
apps/server/src/provider/acp/AcpStderr.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | |||
| 4 | /** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ | ||
| 5 | export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; | ||
| 6 | |||
| 7 | const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; | ||
| 8 | const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; | ||
| 9 | const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; | ||
| 10 | const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; | ||
| 11 | const SECRET_TOKEN_PATTERN = | ||
| 12 | /\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; | ||
| 13 | |||
| 14 | export function appendAcpStderrTail(current: string, chunk: string): string { | ||
| 15 | const next = `${current}${chunk}`; | ||
| 16 | return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); | ||
| 17 | } | ||
| 18 | |||
| 19 | /** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ | ||
| 20 | export function sanitizeAcpStderrExcerpt( | ||
| 21 | text: string, | ||
| 22 | environment: NodeJS.ProcessEnv = process.env, | ||
| 23 | ): string { | ||
| 24 | let result = text.replaceAll("\0", ""); | ||
| 25 | const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( | ||
| 26 | (value): value is string => typeof value === "string" && value.length > 1, | ||
| 27 | ); | ||
| 28 | for (const home of new Set(homes)) { | ||
| 29 | result = result.split(home).join("~"); | ||
| 30 | } | ||
| 31 | result = result | ||
| 32 | .replace(PAIRING_URL_PATTERN, "[pairing-url]") | ||
| 33 | .replace(BEARER_TOKEN_PATTERN, "Bearer [redacted]") | ||
| 34 | .replace(BASIC_AUTH_PATTERN, "Authorization: Basic [redacted]") | ||
| 35 | .replace(API_KEY_HEADER_PATTERN, "x-api-key: [redacted]") | ||
| 36 | .replace(SECRET_TOKEN_PATTERN, "[redacted]"); | ||
| 37 | return result.trim(); | ||
| 38 | } | ||
packages/effect-acp/src/errors.ts+5-3| ... | @@ -95,13 +95,15 @@ export class AcpProcessExitedError extends Schema.TaggedError<AcpProcessExitedEr | ... | @@ -95,13 +95,15 @@ export class AcpProcessExitedError extends Schema.TaggedError<AcpProcessExitedEr |
| 95 | { | 95 | { |
| 96 | code: Schema.optional(Schema.Number), | 96 | code: Schema.optional(Schema.Number), |
| 97 | pid: Schema.optionalKey(Schema.Int), | 97 | pid: Schema.optionalKey(Schema.Int), |
| 98 | stderr: Schema.optionalKey(Schema.String), | ||
| 98 | cause: Schema.optional(Schema.Defect()), | 99 | cause: Schema.optional(Schema.Defect()), |
| 99 | }, | 100 | }, |
| 100 | ) { | 101 | ) { |
| 101 | override get message() { | 102 | override get message() { |
| 102 | return this.code === undefined | 103 | const base = |
| 103 | ? "ACP process exited" | 104 | this.code === undefined ? "ACP process exited" : `ACP process exited with code ${this.code}`; |
| 104 | : `ACP process exited with code ${this.code}`; | 105 | const excerpt = this.stderr?.trim(); |
| 106 | return excerpt && excerpt.length > 0 ? `${base}\n${excerpt}` : base; | ||
| 105 | } | 107 | } |
| 106 | } | 108 | } |
| 107 | 109 |