| 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 | 36 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 37 | 37 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; |
| 38 | 38 | import { |
| 39 | ProviderAdapterProcessError, | |
| 39 | 40 | ProviderAdapterRequestError, |
| 40 | 41 | ProviderAdapterValidationError, |
| 41 | 42 | ProviderWorkspaceMissingError, |
| ... | ... | @@ -64,6 +65,7 @@ import { |
| 64 | 65 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 65 | 66 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 66 | 67 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 68 | const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); | |
| 67 | 69 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); |
| 68 | 70 | const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); |
| 69 | 71 | const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); |
| ... | ... | @@ -373,6 +375,9 @@ const make = Effect.gen(function* () { |
| 373 | 375 | if (isProviderAdapterRequestError(failReason?.error)) { |
| 374 | 376 | return failReason.error.detail; |
| 375 | 377 | } |
| 378 | if (isProviderAdapterProcessError(failReason?.error)) { | |
| 379 | return failReason.error.detail; | |
| 380 | } | |
| 376 | 381 | if (isProviderAdapterValidationError(failReason?.error)) { |
| 377 | 382 | return failReason.error.issue; |
| 378 | 383 | } |
apps/server/src/provider/Layers/CursorAdapter.test.ts+43| ... | ... | @@ -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 | 589 | it.effect("maps app plan mode onto the ACP plan session mode", () => |
| 547 | 590 | Effect.gen(function* () { |
| 548 | 591 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+36| ... | ... | @@ -25,4 +25,40 @@ describe("AcpAdapterSupport", () => { |
| 25 | 25 | expect(error._tag).toBe("ProviderAdapterRequestError"); |
| 26 | 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 | 7 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | ProviderAdapterProcessError, | |
| 10 | 11 | ProviderAdapterRequestError, |
| 11 | ProviderAdapterSessionClosedError, | |
| 12 | 12 | type ProviderAdapterError, |
| 13 | 13 | } from "../Errors.ts"; |
| 14 | 14 | const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); |
| ... | ... | @@ -21,9 +21,10 @@ export function mapAcpToAdapterError( |
| 21 | 21 | error: EffectAcpErrors.AcpError, |
| 22 | 22 | ): ProviderAdapterError { |
| 23 | 23 | if (isAcpProcessExitedError(error)) { |
| 24 | return new ProviderAdapterSessionClosedError({ | |
| 24 | return new ProviderAdapterProcessError({ | |
| 25 | 25 | provider, |
| 26 | 26 | threadId, |
| 27 | detail: error.message, | |
| 27 | 28 | cause: error, |
| 28 | 29 | }); |
| 29 | 30 | } |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+20| ... | ... | @@ -368,6 +368,26 @@ describe("AcpSessionRuntime", () => { |
| 368 | 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 | 391 | it.effect("drains large stderr output and keeps auth-sized logging chunks", () => |
| 372 | 392 | Effect.gen(function* () { |
| 373 | 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 | 22 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 23 | 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 24 | 24 | |
| 25 | import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; | |
| 25 | 26 | import { |
| 26 | 27 | collectSessionConfigOptionValues, |
| 27 | 28 | decideToolCallUpdateEmission, |
| ... | ... | @@ -354,6 +355,8 @@ export const make = ( |
| 354 | 355 | ); |
| 355 | 356 | const stoppingRef = yield* Ref.make(false); |
| 356 | 357 | const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>(); |
| 358 | const stderrTailRef = yield* Ref.make(""); | |
| 359 | const stderrDrained = yield* Deferred.make<void>(); | |
| 357 | 360 | const runtimeClosed = yield* Deferred.make<void>(); |
| 358 | 361 | const promptSerializationSemaphore = yield* Semaphore.make(1); |
| 359 | 362 | const promptDispatchSemaphore = yield* Semaphore.make(1); |
| ... | ... | @@ -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 | 402 | const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( |
| 378 | 403 | error: EffectAcpErrors.AcpError, |
| 379 | 404 | ) { |
| 380 | 405 | if (yield* Ref.get(stoppingRef)) { |
| 381 | 406 | return; |
| 382 | 407 | } |
| 408 | const enriched = yield* enrichProcessExitWithStderr(error); | |
| 383 | 409 | const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => |
| 384 | 410 | Option.isSome(current) |
| 385 | 411 | ? ([false, current] as const) |
| 386 | : ([true, Option.some(error)] as const), | |
| 412 | : ([true, Option.some(enriched)] as const), | |
| 387 | 413 | ); |
| 388 | 414 | if (!firstTermination) { |
| 389 | 415 | return; |
| 390 | 416 | } |
| 391 | 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 | 421 | const logRequest = (event: AcpSessionRequestLogEvent) => |
| ... | ... | @@ -406,6 +432,9 @@ export const make = ( |
| 406 | 432 | ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) |
| 407 | 433 | : effect |
| 408 | 434 | ).pipe( |
| 435 | Effect.catch((error) => | |
| 436 | enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), | |
| 437 | ), | |
| 409 | 438 | Effect.tap((result) => |
| 410 | 439 | logRequest({ |
| 411 | 440 | method, |
| ... | ... | @@ -453,10 +482,10 @@ export const make = ( |
| 453 | 482 | yield* child.stderr.pipe( |
| 454 | 483 | Stream.decodeText(), |
| 455 | 484 | Stream.runForEach((chunk) => |
| 456 | (options.onStderr | |
| 457 | ? options.onStderr(chunk.slice(-maxStderrChunkLength)) | |
| 458 | : Effect.void | |
| 459 | ).pipe( | |
| 485 | Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( | |
| 486 | Effect.andThen( | |
| 487 | options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, | |
| 488 | ), | |
| 460 | 489 | Effect.catch((error) => |
| 461 | 490 | Effect.gen(function* () { |
| 462 | 491 | yield* Deferred.fail(stderrFailure, error); |
| ... | ... | @@ -466,6 +495,7 @@ export const make = ( |
| 466 | 495 | ), |
| 467 | 496 | ), |
| 468 | 497 | ), |
| 498 | Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), | |
| 469 | 499 | Effect.ignore, |
| 470 | 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 | 95 | { |
| 96 | 96 | code: Schema.optional(Schema.Number), |
| 97 | 97 | pid: Schema.optionalKey(Schema.Int), |
| 98 | stderr: Schema.optionalKey(Schema.String), | |
| 98 | 99 | cause: Schema.optional(Schema.Defect()), |
| 99 | 100 | }, |
| 100 | 101 | ) { |
| 101 | 102 | override get message() { |
| 102 | return this.code === undefined | |
| 103 | ? "ACP process exited" | |
| 104 | : `ACP process exited with code ${this.code}`; | |
| 103 | const base = | |
| 104 | this.code === undefined ? "ACP process exited" : `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 |