authorgravatar for yashafaid@gmail.comCestercian <yashafaid@gmail.com> 2026-09-20 03:42:03+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-19 15:12:03-07:00
logd6f291303ddc0c9a14f570266a4d9eff6d431593
tree90d115e4249a741d352016718c1a0526d4e5b2b7
parent52d08a14b9e475371658fabbb12c056c3d3dad67
signature Signed by PGP key B5690EEEBB952194

fix(server): surface ACP stderr when cursor-agent exits at session start (#12625)

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";
36import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";36import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
37import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";37import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
38import {38import {
39 ProviderAdapterProcessError,
39 ProviderAdapterRequestError,40 ProviderAdapterRequestError,
40 ProviderAdapterValidationError,41 ProviderAdapterValidationError,
41 ProviderWorkspaceMissingError,42 ProviderWorkspaceMissingError,
...@@ -64,6 +65,7 @@ import {...@@ -64,6 +65,7 @@ import {
64import { resolveProjectSettings } from "@t3tools/shared/projectSettings";65import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
65import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";66import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
66import { GitWorkflowService } from "../../git/GitWorkflowService.ts";67import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
68const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);
67const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);69const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
68const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);70const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
69const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError);71const 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 );
545545
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";
7import * as EffectAcpErrors from "effect-acp/errors";7import * as EffectAcpErrors from "effect-acp/errors";
88
9import {9import {
10 ProviderAdapterProcessError,
10 ProviderAdapterRequestError,11 ProviderAdapterRequestError,
11 ProviderAdapterSessionClosedError,
12 type ProviderAdapterError,12 type ProviderAdapterError,
13} from "../Errors.ts";13} from "../Errors.ts";
14const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError);14const 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 );
370370
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";
22import type * as EffectAcpProtocol from "effect-acp/protocol";22import type * as EffectAcpProtocol from "effect-acp/protocol";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";23import { resolveSpawnCommand } from "@t3tools/shared/shell";
2424
25import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts";
25import {26import {
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 });
376379
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 });
394420
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 : effect433 : 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.onStderr485 Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe(
457 ? options.onStderr(chunk.slice(-maxStderrChunkLength))486 Effect.andThen(
458 : Effect.void487 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 ACP_STDERR_TAIL_MAX_CHARS,
5 appendAcpStderrTail,
6 sanitizeAcpStderrExcerpt,
7} from "./AcpStderr.ts";
8
9describe("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.
2import * as NodeOS from "node:os";
3
4/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */
5export const ACP_STDERR_TAIL_MAX_CHARS = 4_096;
6
7const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi;
8const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi;
9const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi;
10const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi;
11const 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
14export 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. */
20export 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 === undefined103 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}
107109