@zgeoff/atc 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -5,7 +5,7 @@ import { isRecord } from '../shared/report';
5
5
  * per line. Three message kinds, distinguished by which fields are present —
6
6
  * request (id + m), response (id + ok or err), event (ev).
7
7
  */
8
- export const PROTOCOL_V = 3;
8
+ export const PROTOCOL_V = 4;
9
9
 
10
10
  // Control lines are capped before buffering; PTY output is split into chunks
11
11
  // so a queued response is delayed by at most one chunk.
@@ -20,8 +20,40 @@ const ERROR_CODES = [
20
20
  'no_such_session',
21
21
  'session_dead',
22
22
  'unsupported',
23
+ 'unsupported_operation',
24
+ 'unknown_target',
25
+ 'target_unavailable',
26
+ 'target_changed',
27
+ 'target_config_invalid',
28
+ 'target_forbidden',
29
+ 'not_a_git_repo',
30
+ 'no_commits',
31
+ 'unreadable_tree',
32
+ 'has_submodules',
33
+ 'lfs_unsupported',
34
+ 'workspace_dirty',
35
+ 'no_origin',
36
+ 'invalid_git_url',
37
+ 'unpushed_head',
38
+ 'credential_in_url',
39
+ 'credential_missing',
40
+ 'ref_not_found',
41
+ 'clone_failed',
42
+ 'sanitize_failed',
43
+ 'tar_failed',
44
+ 'workspace_exists',
45
+ 'transfer_failed',
46
+ 'workspace_mismatch',
47
+ 'host_unavailable',
48
+ 'auth_not_configured',
49
+ 'host_leased',
50
+ 'confirmation_required',
51
+ 'confirm_token_invalid',
23
52
  'already_answered',
24
53
  'too_slow',
54
+ 'stale_epoch',
55
+ 'idempotency_conflict',
56
+ 'outcome_unknown',
25
57
  'internal',
26
58
  ] as const;
27
59
 
@@ -30,6 +62,9 @@ export type ErrorCode = (typeof ERROR_CODES)[number];
30
62
  interface ProtocolError {
31
63
  readonly code: ErrorCode;
32
64
  readonly msg: string;
65
+
66
+ // Structured detail an error code defines for itself.
67
+ readonly data?: Readonly<Record<string, unknown>>;
33
68
  }
34
69
 
35
70
  export interface RequestMsg {
@@ -37,6 +72,10 @@ export interface RequestMsg {
37
72
  readonly id: number;
38
73
  readonly m: string;
39
74
  readonly p?: Readonly<Record<string, unknown>>;
75
+
76
+ // The principal the request acts as, which narrows what the connection
77
+ // may reach for this request alone.
78
+ readonly as?: string;
40
79
  }
41
80
 
42
81
  export interface ResponseMsg {
@@ -61,7 +100,9 @@ export type DecodedMsg =
61
100
  /**
62
101
  * Classifies one NDJSON line. Unknown fields pass through untouched so
63
102
  * additive evolution never breaks a peer; a line that parses but fits no
64
- * message kind is malformed, and the caller closes the connection.
103
+ * message kind is malformed, and the caller closes the connection. An error
104
+ * code this build does not know decodes as `internal` with its message
105
+ * kept, so a peer that adds a code never breaks one that predates it.
65
106
  */
66
107
  export function decodeMessage(line: string): DecodedMsg {
67
108
  let parsed: unknown;
@@ -86,6 +127,13 @@ export function decodeMessage(line: string): DecodedMsg {
86
127
 
87
128
  if (typeof parsed['m'] === 'string') {
88
129
  const p = parsed['p'];
130
+ const as = parsed['as'];
131
+
132
+ // A principal that is not a string would otherwise drop out and leave the
133
+ // request with the connection's whole reach.
134
+ if (as !== undefined && (typeof as !== 'string' || as === '')) {
135
+ return { kind: 'malformed', reason: 'as must be a non-empty string' };
136
+ }
89
137
 
90
138
  return {
91
139
  kind: 'request',
@@ -94,21 +142,22 @@ export function decodeMessage(line: string): DecodedMsg {
94
142
  id: parsed['id'],
95
143
  m: parsed['m'],
96
144
  ...(isRecord(p) ? { p } : {}),
145
+ ...(as === undefined ? {} : { as }),
97
146
  },
98
147
  };
99
148
  }
100
149
 
101
150
  const ok = parsed['ok'];
102
- const err = parsed['err'];
151
+ const err = parseProtocolError(parsed['err']);
103
152
 
104
- if (isRecord(ok) || isProtocolError(err)) {
153
+ if (isRecord(ok) || err !== null) {
105
154
  return {
106
155
  kind: 'response',
107
156
  msg: {
108
157
  v: parsed['v'],
109
158
  id: parsed['id'],
110
159
  ...(isRecord(ok) ? { ok } : {}),
111
- ...(isProtocolError(err) ? { err } : {}),
160
+ ...(err === null ? {} : { err }),
112
161
  },
113
162
  };
114
163
  }
@@ -120,10 +169,17 @@ export function encodeMessage(msg: EventMsg | RequestMsg | ResponseMsg): string
120
169
  return `${JSON.stringify(msg)}\n`;
121
170
  }
122
171
 
123
- function isProtocolError(value: unknown): value is ProtocolError {
124
- return (
125
- isRecord(value) &&
126
- typeof value['msg'] === 'string' &&
127
- ERROR_CODES.some((code) => code === value['code'])
128
- );
172
+ function parseProtocolError(value: unknown): ProtocolError | null {
173
+ if (!isRecord(value) || typeof value['msg'] !== 'string' || typeof value['code'] !== 'string') {
174
+ return null;
175
+ }
176
+
177
+ const raw = value['code'];
178
+ const data = value['data'];
179
+
180
+ return {
181
+ code: ERROR_CODES.find((code) => code === raw) ?? 'internal',
182
+ msg: value['msg'],
183
+ ...(isRecord(data) ? { data } : {}),
184
+ };
129
185
  }
@@ -16,9 +16,72 @@ const SESSION_DEFAULTED = z.object({
16
16
  session: buildDefaultedString('').transform(toSessionID),
17
17
  });
18
18
 
19
+ // The idempotency key a spawn or message may carry, at most 200 characters.
20
+ const IDEMPOTENCY_KEY = z
21
+ .string({ error: 'idempotencyKey must be a string' })
22
+ .min(1, 'idempotencyKey must not be empty')
23
+ .max(200, 'idempotencyKey must be at most 200 characters')
24
+ .optional();
25
+
26
+ // A full commit id, SHA-1 or SHA-256.
27
+ const COMMIT_ID = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
28
+
29
+ // The daemon environment variable a git workspace's token is read from.
30
+ const CREDENTIAL_REF = z.strictObject({
31
+ kind: z.literal('env'),
32
+ name: z.string().regex(/^[A-Za-z_]\w*$/u, 'a credentialRef names an environment variable'),
33
+ });
34
+
35
+ /**
36
+ * Where a spawn's working directory comes from, materialized as a clean
37
+ * checkout into the spawn's `cwd` on its execution target. A `path` source
38
+ * is a directory on the daemon's host, resolved to its origin URL and
39
+ * pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
40
+ * changes to HEAD and leaves the changes behind with a warning. A `git`
41
+ * source is a repository URL with exactly one of a branch or tag `ref` or
42
+ * a full commit `sha`, and an optional `credentialRef` naming the daemon
43
+ * environment variable that holds its token.
44
+ */
45
+ const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
46
+ z.strictObject({
47
+ kind: z.literal('path'),
48
+ path: z
49
+ .string({ error: 'a path workspace requires an absolute path' })
50
+ .startsWith('/', 'a path workspace requires an absolute path'),
51
+ allowDirty: z.enum(['refuse', 'warn']).optional(),
52
+ }),
53
+ z
54
+ .strictObject({
55
+ kind: z.literal('git'),
56
+ url: z
57
+ .string({ error: 'a git workspace requires a url' })
58
+ .min(1, 'a git workspace requires a url')
59
+ .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -'),
60
+ ref: z
61
+ .string()
62
+ .min(1, 'a git workspace ref must not be empty')
63
+ .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -')
64
+ .optional(),
65
+ sha: z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id').optional(),
66
+ credentialRef: CREDENTIAL_REF.optional(),
67
+ })
68
+ .refine((source) => (source.ref === undefined) !== (source.sha === undefined), {
69
+ message: 'a git workspace takes exactly one of ref or sha',
70
+ }),
71
+ ]);
72
+
73
+ export type SpawnWorkspaceSource = z.infer<typeof WORKSPACE_SOURCE>;
74
+
75
+ // The refusal of a terminal size outside the range a terminal takes.
76
+ const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
77
+
19
78
  export const REQUEST_PARAM_SCHEMAS = {
20
79
  'daemon.hello': z.object({
21
80
  client: buildDefaultedString('unknown client'),
81
+ principal: z
82
+ .string({ error: 'daemon.hello principal must be a non-empty string' })
83
+ .min(1, 'daemon.hello principal must be a non-empty string')
84
+ .optional(),
22
85
  }),
23
86
  'daemon.ping': z.object({}),
24
87
  'daemon.quit': z.object({}),
@@ -27,15 +90,15 @@ export const REQUEST_PARAM_SCHEMAS = {
27
90
  'agents.list': z.object({}),
28
91
  'fleet.list': z.object({}),
29
92
  'fleet.restore': z.object({
30
- cols: buildDefaultedNumber(80),
31
- rows: buildDefaultedNumber(24),
93
+ cols: buildTerminalSize(80),
94
+ rows: buildTerminalSize(24),
32
95
  }),
33
96
  'session.spawn': z.object({
34
97
  cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
35
98
  name: buildDefaultedString(''),
36
99
  prompt: buildDefaultedString(''),
37
- cols: buildDefaultedNumber(80),
38
- rows: buildDefaultedNumber(24),
100
+ cols: buildTerminalSize(80),
101
+ rows: buildTerminalSize(24),
39
102
 
40
103
  resume: buildDefaultedBooleanOrString(false).transform((v) =>
41
104
  typeof v === 'string' ? toAgentSessionID(v) : v,
@@ -45,6 +108,27 @@ export const REQUEST_PARAM_SCHEMAS = {
45
108
  .min(1, 'session.spawn agent must be a non-empty agent id')
46
109
  .optional(),
47
110
 
111
+ // Per-session overrides; the daemon checks each against what the agent
112
+ // advertises before anything spawns.
113
+ model: z.string({ error: 'session.spawn model must be a string' }).optional(),
114
+ effort: z.string({ error: 'session.spawn effort must be a string' }).optional(),
115
+
116
+ // A retry carrying the same key replays the first spawn's answer instead
117
+ // of spawning again.
118
+ idempotencyKey: IDEMPOTENCY_KEY,
119
+
120
+ // The execution target the session runs on; absent runs on the default
121
+ // target, and a target the daemon cannot use refuses the spawn.
122
+ target: z
123
+ .string({ error: 'session.spawn target must be a non-empty target id' })
124
+ .min(1, 'session.spawn target must be a non-empty target id')
125
+ .optional(),
126
+
127
+ // Where the session's working directory comes from: absent runs the
128
+ // session in cwd as it stands, and a source materializes a clean
129
+ // checkout into cwd first.
130
+ workspace: WORKSPACE_SOURCE.optional(),
131
+
48
132
  // The session the new one is a sub-session of; absent or empty spawns a
49
133
  // top-level session.
50
134
  parent: z.preprocess(
@@ -54,21 +138,33 @@ export const REQUEST_PARAM_SCHEMAS = {
54
138
  }),
55
139
  'session.kill': SESSION_DEFAULTED,
56
140
  'session.ack': SESSION_DEFAULTED,
141
+
142
+ // Without a token, a forget that destroys a host answers with one; the
143
+ // forget that carries it destroys the host.
144
+ 'session.forget': SESSION_DEFAULTED.extend({
145
+ confirmToken: z
146
+ .string({ error: 'session.forget confirmToken must be a string' })
147
+ .min(1, 'session.forget confirmToken must not be empty')
148
+ .optional(),
149
+ }),
57
150
  'session.update': SESSION_DEFAULTED.extend({
58
151
  name: buildOptionalString(),
59
152
  pinned: buildOptionalBoolean(),
60
153
  }),
61
154
  'session.attach': SESSION_DEFAULTED.extend({
62
- cols: buildDefaultedNumber(80),
63
- rows: buildDefaultedNumber(24),
155
+ cols: buildTerminalSize(80),
156
+ rows: buildTerminalSize(24),
64
157
  }),
65
158
  'session.detach': SESSION_DEFAULTED,
66
159
  'session.input': SESSION_DEFAULTED.extend({
67
160
  d: buildDefaultedString(''),
68
161
  }),
162
+ 'session.submit': SESSION_DEFAULTED.extend({
163
+ text: buildDefaultedString(''),
164
+ }),
69
165
  'session.resize': SESSION_DEFAULTED.extend({
70
- cols: buildDefaultedNumber(0),
71
- rows: buildDefaultedNumber(0),
166
+ cols: buildTerminalSize(0),
167
+ rows: buildTerminalSize(0),
72
168
  }).refine((v) => v.cols >= 1 && v.rows >= 1, {
73
169
  message: 'session.resize requires positive cols and rows',
74
170
  }),
@@ -78,8 +174,8 @@ export const REQUEST_PARAM_SCHEMAS = {
78
174
  prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
79
175
  }),
80
176
  'session.adopt': SESSION_DEFAULTED.extend({
81
- cols: buildDefaultedNumber(80),
82
- rows: buildDefaultedNumber(24),
177
+ cols: buildTerminalSize(80),
178
+ rows: buildTerminalSize(24),
83
179
  }),
84
180
  'permission.respond': z
85
181
  .object({
@@ -108,6 +204,10 @@ export const REQUEST_PARAM_SCHEMAS = {
108
204
  'session.message': SESSION_DEFAULTED.extend({
109
205
  from: buildDefaultedNonEmptyString('unknown'),
110
206
  text: buildDefaultedString(''),
207
+
208
+ // A retry carrying the same key replays the first send's message
209
+ // instead of sending another.
210
+ idempotencyKey: IDEMPOTENCY_KEY,
111
211
  }).refine((v) => v.text !== '', { message: 'session.message requires text' }),
112
212
  'session.tap': SESSION_DEFAULTED,
113
213
  'message.get': z
@@ -125,6 +225,22 @@ function buildDefaultedString(fallback: string) {
125
225
  return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
126
226
  }
127
227
 
228
+ // A terminal dimension: absent or not a number takes the fallback, and a
229
+ // number must be a whole number in range, since the terminal emulator and
230
+ // the PTY both assume one and a fractional size throws only after the
231
+ // process starts.
232
+ function buildTerminalSize(fallback: number) {
233
+ return z.preprocess(
234
+ (v) => (typeof v === 'number' ? v : undefined),
235
+ z
236
+ .number({ error: TERMINAL_SIZE_ERROR })
237
+ .int(TERMINAL_SIZE_ERROR)
238
+ .min(1, TERMINAL_SIZE_ERROR)
239
+ .max(4096, TERMINAL_SIZE_ERROR)
240
+ .default(fallback),
241
+ );
242
+ }
243
+
128
244
  function buildDefaultedNumber(fallback: number) {
129
245
  return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
130
246
  }
package/src/report.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
1
4
  import { sendReport } from './shared/report';
2
5
  import { REPORT_KINDS } from './shared/report-kinds';
6
+ import { sendBridgeRequest } from './shared/send-bridge-request';
3
7
 
4
8
  interface ReportOptions {
5
9
  readonly message: string;
@@ -19,7 +23,8 @@ interface ReportOptions {
19
23
  * message, or every given message at once, plus that turn's id when one is
20
24
  * given; a `note` carries
21
25
  * it as text for the user under the given label, `progress` when none is
22
- * given. Always exits 0 so it never blocks the session it reports on.
26
+ * given. Inside a remote host it goes to the session bridge instead. Always
27
+ * exits 0 so it never blocks the session it reports on.
23
28
  */
24
29
  export async function runReport(kind: string, options: ReportOptions): Promise<void> {
25
30
  try {
@@ -37,7 +42,9 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
37
42
 
38
43
  const payload = buildReportPayload(kind, options, stdin);
39
44
 
40
- if (payload !== null) {
45
+ if (payload !== null && process.env['ATC_BRIDGE'] === '1') {
46
+ await sendBridgeReport(sock, process.env['ATC_OUTBOX'] ?? '', payload);
47
+ } else if (payload !== null) {
41
48
  const line = `${JSON.stringify({ atcId, event: 'Report', payload })}\n`;
42
49
 
43
50
  await sendReport(sock, line, 2000);
@@ -48,6 +55,34 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
48
55
  process.exit(0);
49
56
  }
50
57
 
58
+ // Inside a remote host, a report goes to the session bridge under an id of
59
+ // its own, and waits in the outbox until the bridge takes or refuses it, so
60
+ // the session's tap sends it again after a dropped connection. A resent
61
+ // report lands once, and a refused one is never resent.
62
+ async function sendBridgeReport(
63
+ sock: string,
64
+ outbox: string,
65
+ payload: Readonly<Record<string, string | readonly string[]>>,
66
+ ): Promise<void> {
67
+ const reportID = randomUUID();
68
+ const file = outbox === '' ? null : join(outbox, `${reportID}.json`);
69
+
70
+ if (file !== null) {
71
+ try {
72
+ mkdirSync(outbox, { recursive: true });
73
+ writeFileSync(file, JSON.stringify({ reportID, payload }));
74
+ } catch {}
75
+ }
76
+
77
+ const answer = await sendBridgeRequest(sock, 'report', { reportID, payload }, 2000);
78
+
79
+ const isFinal = answer?.['ok'] === true || answer?.['code'] === 'forbidden';
80
+
81
+ if (isFinal && file !== null) {
82
+ rmSync(file, { force: true });
83
+ }
84
+ }
85
+
51
86
  function buildReportPayload(
52
87
  kind: string,
53
88
  options: ReportOptions,
@@ -0,0 +1,241 @@
1
+ import { readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { openBridgeSocket } from './shared/open-bridge-socket';
5
+ import type { BridgeSocket } from './shared/open-bridge-socket';
6
+
7
+ const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
+ ev: z.literal('InboxMessage'),
9
+ message: z.string(),
10
+ from: z.string(),
11
+ text: z.string(),
12
+ sentAt: z.number(),
13
+ });
14
+
15
+ const OUTBOX_REPORT_SCHEMA = z.object({
16
+ reportID: z.string().min(1),
17
+ payload: z.record(z.string(), z.unknown()),
18
+ });
19
+
20
+ // The wait before the first reconnect, doubling up to the longest.
21
+ const FIRST_RETRY_MS = 250;
22
+ const LONGEST_RETRY_MS = 5000;
23
+
24
+ // How many message ids the tap remembers having printed.
25
+ const SEEN_LIMIT = 10_000;
26
+
27
+ // How one connection to the bridge ended: the inbox closed for good, the
28
+ // bridge refused the tap, or the connection dropped after or before it
29
+ // opened the tap.
30
+ type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
31
+
32
+ /**
33
+ * Streams the session's inbox to stdout from inside a remote host, through
34
+ * the session bridge at the given socket. It prints each message once as
35
+ * one NDJSON line and acks it after the line is written. A dropped
36
+ * connection, as a host's sleep or a daemon restart leaves it, reconnects
37
+ * with a growing wait of at most 5 seconds and no end; each new connection
38
+ * replays the messages not yet acked, and one already printed is acked
39
+ * again and not printed twice. Each connection also sends the reports the
40
+ * outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
41
+ * refuses the tap.
42
+ */
43
+ export async function runBridgeTap(socketPath: string, outbox: string): Promise<void> {
44
+ const seen = new Set<string>();
45
+
46
+ let wait = FIRST_RETRY_MS;
47
+
48
+ for (;;) {
49
+ const end = await runTapConnection(socketPath, outbox, seen);
50
+
51
+ if (end === 'closed') {
52
+ process.exit(0);
53
+ }
54
+
55
+ if (end === 'refused') {
56
+ console.error('atc tap: the session bridge refused the tap');
57
+ process.exit(1);
58
+ }
59
+
60
+ if (end === 'lost') {
61
+ wait = FIRST_RETRY_MS;
62
+ }
63
+
64
+ await Bun.sleep(wait);
65
+
66
+ wait = Math.min(wait * 2, LONGEST_RETRY_MS);
67
+ }
68
+ }
69
+
70
+ async function runTapConnection(
71
+ socketPath: string,
72
+ outbox: string,
73
+
74
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
75
+ seen: Set<string>,
76
+ ): Promise<TapEnd> {
77
+ const ended = Promise.withResolvers<TapEnd>();
78
+ let opened = false;
79
+ let printing: Promise<void> = Promise.resolve();
80
+ let socket: BridgeSocket;
81
+
82
+ // The outbox file behind each report this connection sent, by request id.
83
+ let sent = new Map<string, string>();
84
+
85
+ const onLine = (line: Readonly<Record<string, unknown>>) => {
86
+ if (line['ev'] === 'InboxClosed') {
87
+ ended.resolve('closed');
88
+
89
+ return;
90
+ }
91
+
92
+ if (line['id'] === 'tap.open') {
93
+ if (line['ok'] !== true) {
94
+ ended.resolve('refused');
95
+
96
+ return;
97
+ }
98
+
99
+ opened = true;
100
+ sent = sendOutboxReports(socket, outbox);
101
+
102
+ return;
103
+ }
104
+
105
+ // A report the bridge took, or one it refuses outright, leaves the
106
+ // outbox: no resend would change the answer. Only the file this
107
+ // connection sent under the answered id is removed.
108
+ if (typeof line['id'] === 'string' && line['id'].startsWith('report:')) {
109
+ const path = sent.get(line['id']);
110
+
111
+ if (path !== undefined && (line['ok'] === true || line['code'] === 'forbidden')) {
112
+ sent.delete(line['id']);
113
+
114
+ rmSync(path, { force: true });
115
+ }
116
+
117
+ return;
118
+ }
119
+
120
+ if (line['ok'] === false && line['code'] === 'stale_binding') {
121
+ ended.resolve('refused');
122
+
123
+ return;
124
+ }
125
+
126
+ const msg = INBOX_MESSAGE_SCHEMA.safeParse(line);
127
+
128
+ if (!msg.success) {
129
+ return;
130
+ }
131
+
132
+ const message = msg.data;
133
+ const previous = printing;
134
+
135
+ printing = (async () => {
136
+ await previous;
137
+
138
+ if (!seen.has(message.message)) {
139
+ const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
140
+
141
+ try {
142
+ await Bun.write(Bun.stdout, printed);
143
+ } catch {
144
+ process.exit(1);
145
+ }
146
+
147
+ updateSeenIDs(seen, message.message);
148
+ }
149
+
150
+ socket.writeLine({
151
+ v: 1,
152
+ id: `ack:${message.message}`,
153
+ op: 'tap.ack',
154
+ message: message.message,
155
+ });
156
+ })();
157
+ };
158
+
159
+ try {
160
+ socket = await openBridgeSocket(socketPath, onLine);
161
+ } catch {
162
+ return 'unreachable';
163
+ }
164
+
165
+ void (async () => {
166
+ await socket.closed;
167
+
168
+ const end: TapEnd = opened ? 'lost' : 'unreachable';
169
+
170
+ ended.resolve(end);
171
+ })();
172
+
173
+ socket.writeLine({ v: 1, id: 'tap.open', op: 'tap.open' });
174
+
175
+ const end = await ended.promise;
176
+
177
+ await printing.catch(() => {});
178
+
179
+ socket.end();
180
+
181
+ return end;
182
+ }
183
+
184
+ // Sends every report the outbox holds, and returns the file behind each
185
+ // request id it sent; a file that is no report is removed, since no answer
186
+ // would ever clear it.
187
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle
188
+ function sendOutboxReports(socket: BridgeSocket, outbox: string): Map<string, string> {
189
+ const sent = new Map<string, string>();
190
+
191
+ let files: string[];
192
+
193
+ try {
194
+ files = readdirSync(outbox).filter((file) => file.endsWith('.json'));
195
+ } catch {
196
+ return sent;
197
+ }
198
+
199
+ for (const file of files) {
200
+ const path = join(outbox, file);
201
+ let report: z.infer<typeof OUTBOX_REPORT_SCHEMA> | null = null;
202
+
203
+ try {
204
+ const parsed = OUTBOX_REPORT_SCHEMA.safeParse(JSON.parse(readFileSync(path, 'utf8')));
205
+
206
+ report = parsed.success ? parsed.data : null;
207
+ } catch {}
208
+
209
+ if (report === null) {
210
+ rmSync(path, { force: true });
211
+ continue;
212
+ }
213
+
214
+ const id = `report:${report.reportID}`;
215
+
216
+ sent.set(id, path);
217
+
218
+ socket.writeLine({
219
+ v: 1,
220
+ id,
221
+ op: 'report',
222
+ reportID: report.reportID,
223
+ payload: report.payload,
224
+ });
225
+ }
226
+
227
+ return sent;
228
+ }
229
+
230
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the set grows in place
231
+ function updateSeenIDs(seen: Set<string>, id: string): void {
232
+ seen.add(id);
233
+
234
+ for (const oldest of seen) {
235
+ if (seen.size <= SEEN_LIMIT) {
236
+ break;
237
+ }
238
+
239
+ seen.delete(oldest);
240
+ }
241
+ }
@@ -3,6 +3,6 @@ import type { Tagged } from 'type-fest';
3
3
  /**
4
4
  * The session id an agent CLI issues for its own resume mechanism — what
5
5
  * `claude --resume <id>` takes. Adapters mint it from a hook payload or a
6
- * stored fleet row, and the fleet table keys on it.
6
+ * stored fleet row, and a fleet row holds at most one.
7
7
  */
8
8
  export type AgentSessionID = Tagged<string, 'AgentSessionID'>;
@@ -3,14 +3,22 @@
3
3
  * scrubbed out. A child that inherits those variables behaves as part of
4
4
  * the parent session — Claude transcripts nest, and Grok joins the
5
5
  * parent's in-process dashboard — which breaks resume and isolation.
6
+ *
7
+ * Variables named in `withheld` are left out of what the child inherits,
8
+ * such as a workspace credential that only the daemon's own git commands
9
+ * may see. The extras the caller sets explicitly are added after, so one
10
+ * of them is kept even when it shares a withheld name.
6
11
  */
7
12
  export function collectCleanEnv(
8
13
  extra: Readonly<Record<string, string>> = {},
14
+ withheld: readonly string[] = [],
9
15
  ): Record<string, string> {
10
16
  const env: Record<string, string> = {};
11
17
 
18
+ const skipped = new Set(withheld);
19
+
12
20
  for (const [key, value] of Object.entries(process.env)) {
13
- if (value === undefined || isParentSessionKey(key)) {
21
+ if (value === undefined || isParentSessionKey(key) || skipped.has(key)) {
14
22
  continue;
15
23
  }
16
24