@zgeoff/atc 2.11.0 → 2.12.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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -1,7 +1,9 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { match } from 'ts-pattern';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import type { DaemonFeature } from '../protocol/daemon-features';
4
5
  import { isRecord } from '../shared/report';
6
+ import { parseIdempotencyKey } from './parse-idempotency-key';
5
7
  import type { FleetCaller, ToolContext } from './types';
6
8
 
7
9
  /**
@@ -33,20 +35,37 @@ export function runTool(
33
35
  .with('atc_session_spawn', async () => {
34
36
  const rawAgent = args['agent'];
35
37
  const nested = args['detached'] !== true && ctx.callerSessionID !== null;
38
+ const key = parseIdempotencyKey(args['idempotencyKey']);
36
39
 
37
40
  const params = {
38
41
  cwd: args['cwd'],
39
42
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
40
43
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
41
44
  ...(rawAgent === undefined ? {} : { agent: rawAgent }),
45
+ ...(args['model'] === undefined ? {} : { model: args['model'] }),
46
+ ...(args['effort'] === undefined ? {} : { effort: args['effort'] }),
47
+ ...(key === undefined ? {} : { idempotencyKey: key }),
48
+ ...(args['target'] === undefined ? {} : { target: args['target'] }),
42
49
  cols: 100,
43
50
  rows: 30,
44
51
  };
45
52
 
53
+ // A model, effort, key, or target needs a daemon that takes them; the check
54
+ // runs on every connection the spawn rides.
55
+ const optionFeatures: readonly DaemonFeature[] =
56
+ args['model'] === undefined && args['effort'] === undefined ? [] : ['spawn.options'];
57
+
58
+ const keyFeatures: readonly DaemonFeature[] = key === undefined ? [] : ['spawn.idempotency'];
59
+
60
+ const targetFeatures: readonly DaemonFeature[] =
61
+ args['target'] === undefined ? [] : ['spawn.target'];
62
+
63
+ const required = [...optionFeatures, ...keyFeatures, ...targetFeatures];
64
+
46
65
  const ok =
47
66
  nested && ctx.callerSessionID !== null
48
- ? await sendNestedSpawn(caller, params, ctx.callerSessionID)
49
- : await caller.sendRequest('session.spawn', params);
67
+ ? await sendNestedSpawn(caller, params, ctx.callerSessionID, required)
68
+ : await caller.sendRequest('session.spawn', params, required);
50
69
 
51
70
  return buildObjectResult(ok['session']);
52
71
  })
@@ -142,11 +161,19 @@ export function runTool(
142
161
  ? given
143
162
  : ctx.sender.name;
144
163
 
145
- const ok = await caller.sendRequest('session.message', {
146
- session: args['session'],
147
- text: args['text'],
148
- from,
149
- });
164
+ const key = parseIdempotencyKey(args['idempotencyKey']);
165
+ const required: DaemonFeature[] = key === undefined ? [] : ['message.idempotency'];
166
+
167
+ const ok = await caller.sendRequest(
168
+ 'session.message',
169
+ {
170
+ session: args['session'],
171
+ text: args['text'],
172
+ from,
173
+ ...(key === undefined ? {} : { idempotencyKey: key }),
174
+ },
175
+ required,
176
+ );
150
177
 
151
178
  return buildObjectResult(ok);
152
179
  })
@@ -177,19 +204,44 @@ function buildObjectResult(value: unknown): ToolResult {
177
204
 
178
205
  // The inherited id can point at a session another daemon hosts, or one
179
206
  // this daemon no longer lists; the spawn then lands top-level instead of
180
- // failing the tool call.
207
+ // failing the tool call. The top-level spawn has a different payload, so it
208
+ // runs under its own key, a fixed-length hash of the caller's: a retry of the
209
+ // tool call derives the same key and replays it rather than conflicting with
210
+ // the nested attempt's key, and the derived key fits the daemon's cap
211
+ // whatever the caller's length. An answer
212
+ // that holds an effect id is a keyed spawn that already ran, which a
213
+ // top-level spawn would only duplicate.
181
214
  async function sendNestedSpawn(
182
215
  caller: FleetCaller,
183
216
  params: Readonly<Record<string, unknown>>,
184
217
  parent: string,
218
+ required: readonly DaemonFeature[],
185
219
  ): Promise<Readonly<Record<string, unknown>>> {
186
220
  try {
187
- return await caller.sendRequest('session.spawn', { ...params, parent });
221
+ return await caller.sendRequest('session.spawn', { ...params, parent }, required);
188
222
  } catch (error) {
189
- if (error instanceof DaemonError && error.code === 'no_such_session') {
190
- return caller.sendRequest('session.spawn', params);
223
+ if (
224
+ error instanceof DaemonError &&
225
+ error.code === 'no_such_session' &&
226
+ error.data?.['effectRef'] === undefined
227
+ ) {
228
+ return caller.sendRequest('session.spawn', buildTopLevelParams(params), required);
191
229
  }
192
230
 
193
231
  throw error;
194
232
  }
195
233
  }
234
+
235
+ function buildTopLevelParams(
236
+ params: Readonly<Record<string, unknown>>,
237
+ ): Readonly<Record<string, unknown>> {
238
+ const key = params['idempotencyKey'];
239
+
240
+ if (typeof key !== 'string') {
241
+ return params;
242
+ }
243
+
244
+ const digest = createHash('sha256').update(key).digest('hex');
245
+
246
+ return { ...params, idempotencyKey: `top-level:${digest}` };
247
+ }
package/src/mcp/types.ts CHANGED
@@ -6,11 +6,13 @@ import type { openMCPAuth } from './open-mcp-auth';
6
6
  // features the connected daemon announced at its handshake. A request that
7
7
  // lists required features is checked against the connection it is about to
8
8
  // ride, every time it is sent, and refused unsent when that daemon lacks one.
9
+ // A request with a principal acts as that principal.
9
10
  export interface FleetCaller {
10
11
  readonly sendRequest: (
11
12
  m: string,
12
13
  p?: Readonly<Record<string, unknown>>,
13
14
  required?: readonly DaemonFeature[],
15
+ principal?: string,
14
16
  ) => Promise<Readonly<Record<string, unknown>>>;
15
17
  readonly readFeatures: () => Promise<ReadonlySet<DaemonFeature>>;
16
18
  }
@@ -7,10 +7,14 @@ import type { ErrorCode } from './protocol';
7
7
  export class DaemonError extends Error {
8
8
  readonly code: ErrorCode;
9
9
 
10
- constructor(code: ErrorCode, msg: string) {
10
+ // Structured detail the error code defines for itself, sent as `err.data`.
11
+ readonly data: Readonly<Record<string, unknown>> | undefined;
12
+
13
+ constructor(code: ErrorCode, msg: string, data?: Readonly<Record<string, unknown>>) {
11
14
  super(msg);
12
15
 
13
16
  this.code = code;
17
+ this.data = data;
14
18
  this.name = 'DaemonError';
15
19
  }
16
20
  }
@@ -18,6 +18,30 @@ export const DAEMON_FEATURES = [
18
18
 
19
19
  // `message.get` takes `waitMs`.
20
20
  'message.wait',
21
+
22
+ // `session.spawn` takes `model` and `effort`, and `agents.list` returns
23
+ // `spawnOptions`.
24
+ 'spawn.options',
25
+
26
+ // `daemon.hello` returns `daemonID`.
27
+ 'daemon.id',
28
+
29
+ // Every session descriptor holds a `locator`.
30
+ 'session.locator',
31
+
32
+ // `session.spawn` takes `idempotencyKey`.
33
+ 'spawn.idempotency',
34
+
35
+ // `session.message` takes `idempotencyKey`.
36
+ 'message.idempotency',
37
+
38
+ // `session.spawn` takes `target`, and `agents.list` returns `targets`,
39
+ // `spawnDefaults`, `configRevision`, and `targetErrors`.
40
+ 'spawn.target',
41
+
42
+ // A request takes `as`, the principal it acts as, and `daemon.hello`
43
+ // takes `principal`, the principal the whole connection acts as.
44
+ 'request.principal',
21
45
  ] as const;
22
46
 
23
47
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -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,17 @@ 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',
23
29
  'already_answered',
24
30
  'too_slow',
31
+ 'stale_epoch',
32
+ 'idempotency_conflict',
33
+ 'outcome_unknown',
25
34
  'internal',
26
35
  ] as const;
27
36
 
@@ -30,6 +39,9 @@ export type ErrorCode = (typeof ERROR_CODES)[number];
30
39
  interface ProtocolError {
31
40
  readonly code: ErrorCode;
32
41
  readonly msg: string;
42
+
43
+ // Structured detail an error code defines for itself.
44
+ readonly data?: Readonly<Record<string, unknown>>;
33
45
  }
34
46
 
35
47
  export interface RequestMsg {
@@ -37,6 +49,10 @@ export interface RequestMsg {
37
49
  readonly id: number;
38
50
  readonly m: string;
39
51
  readonly p?: Readonly<Record<string, unknown>>;
52
+
53
+ // The principal the request acts as, which narrows what the connection
54
+ // may reach for this request alone.
55
+ readonly as?: string;
40
56
  }
41
57
 
42
58
  export interface ResponseMsg {
@@ -61,7 +77,9 @@ export type DecodedMsg =
61
77
  /**
62
78
  * Classifies one NDJSON line. Unknown fields pass through untouched so
63
79
  * additive evolution never breaks a peer; a line that parses but fits no
64
- * message kind is malformed, and the caller closes the connection.
80
+ * message kind is malformed, and the caller closes the connection. An error
81
+ * code this build does not know decodes as `internal` with its message
82
+ * kept, so a peer that adds a code never breaks one that predates it.
65
83
  */
66
84
  export function decodeMessage(line: string): DecodedMsg {
67
85
  let parsed: unknown;
@@ -86,6 +104,13 @@ export function decodeMessage(line: string): DecodedMsg {
86
104
 
87
105
  if (typeof parsed['m'] === 'string') {
88
106
  const p = parsed['p'];
107
+ const as = parsed['as'];
108
+
109
+ // A principal that is not a string would otherwise drop out and leave the
110
+ // request with the connection's whole reach.
111
+ if (as !== undefined && (typeof as !== 'string' || as === '')) {
112
+ return { kind: 'malformed', reason: 'as must be a non-empty string' };
113
+ }
89
114
 
90
115
  return {
91
116
  kind: 'request',
@@ -94,21 +119,22 @@ export function decodeMessage(line: string): DecodedMsg {
94
119
  id: parsed['id'],
95
120
  m: parsed['m'],
96
121
  ...(isRecord(p) ? { p } : {}),
122
+ ...(as === undefined ? {} : { as }),
97
123
  },
98
124
  };
99
125
  }
100
126
 
101
127
  const ok = parsed['ok'];
102
- const err = parsed['err'];
128
+ const err = parseProtocolError(parsed['err']);
103
129
 
104
- if (isRecord(ok) || isProtocolError(err)) {
130
+ if (isRecord(ok) || err !== null) {
105
131
  return {
106
132
  kind: 'response',
107
133
  msg: {
108
134
  v: parsed['v'],
109
135
  id: parsed['id'],
110
136
  ...(isRecord(ok) ? { ok } : {}),
111
- ...(isProtocolError(err) ? { err } : {}),
137
+ ...(err === null ? {} : { err }),
112
138
  },
113
139
  };
114
140
  }
@@ -120,10 +146,17 @@ export function encodeMessage(msg: EventMsg | RequestMsg | ResponseMsg): string
120
146
  return `${JSON.stringify(msg)}\n`;
121
147
  }
122
148
 
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
- );
149
+ function parseProtocolError(value: unknown): ProtocolError | null {
150
+ if (!isRecord(value) || typeof value['msg'] !== 'string' || typeof value['code'] !== 'string') {
151
+ return null;
152
+ }
153
+
154
+ const raw = value['code'];
155
+ const data = value['data'];
156
+
157
+ return {
158
+ code: ERROR_CODES.find((code) => code === raw) ?? 'internal',
159
+ msg: value['msg'],
160
+ ...(isRecord(data) ? { data } : {}),
161
+ };
129
162
  }
@@ -16,9 +16,23 @@ 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
+ // The refusal of a terminal size outside the range a terminal takes.
27
+ const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
28
+
19
29
  export const REQUEST_PARAM_SCHEMAS = {
20
30
  'daemon.hello': z.object({
21
31
  client: buildDefaultedString('unknown client'),
32
+ principal: z
33
+ .string({ error: 'daemon.hello principal must be a non-empty string' })
34
+ .min(1, 'daemon.hello principal must be a non-empty string')
35
+ .optional(),
22
36
  }),
23
37
  'daemon.ping': z.object({}),
24
38
  'daemon.quit': z.object({}),
@@ -27,15 +41,15 @@ export const REQUEST_PARAM_SCHEMAS = {
27
41
  'agents.list': z.object({}),
28
42
  'fleet.list': z.object({}),
29
43
  'fleet.restore': z.object({
30
- cols: buildDefaultedNumber(80),
31
- rows: buildDefaultedNumber(24),
44
+ cols: buildTerminalSize(80),
45
+ rows: buildTerminalSize(24),
32
46
  }),
33
47
  'session.spawn': z.object({
34
48
  cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
35
49
  name: buildDefaultedString(''),
36
50
  prompt: buildDefaultedString(''),
37
- cols: buildDefaultedNumber(80),
38
- rows: buildDefaultedNumber(24),
51
+ cols: buildTerminalSize(80),
52
+ rows: buildTerminalSize(24),
39
53
 
40
54
  resume: buildDefaultedBooleanOrString(false).transform((v) =>
41
55
  typeof v === 'string' ? toAgentSessionID(v) : v,
@@ -45,6 +59,22 @@ export const REQUEST_PARAM_SCHEMAS = {
45
59
  .min(1, 'session.spawn agent must be a non-empty agent id')
46
60
  .optional(),
47
61
 
62
+ // Per-session overrides; the daemon checks each against what the agent
63
+ // advertises before anything spawns.
64
+ model: z.string({ error: 'session.spawn model must be a string' }).optional(),
65
+ effort: z.string({ error: 'session.spawn effort must be a string' }).optional(),
66
+
67
+ // A retry carrying the same key replays the first spawn's answer instead
68
+ // of spawning again.
69
+ idempotencyKey: IDEMPOTENCY_KEY,
70
+
71
+ // The execution target the session runs on; absent runs on the default
72
+ // target, and a target the daemon cannot use refuses the spawn.
73
+ target: z
74
+ .string({ error: 'session.spawn target must be a non-empty target id' })
75
+ .min(1, 'session.spawn target must be a non-empty target id')
76
+ .optional(),
77
+
48
78
  // The session the new one is a sub-session of; absent or empty spawns a
49
79
  // top-level session.
50
80
  parent: z.preprocess(
@@ -59,16 +89,16 @@ export const REQUEST_PARAM_SCHEMAS = {
59
89
  pinned: buildOptionalBoolean(),
60
90
  }),
61
91
  'session.attach': SESSION_DEFAULTED.extend({
62
- cols: buildDefaultedNumber(80),
63
- rows: buildDefaultedNumber(24),
92
+ cols: buildTerminalSize(80),
93
+ rows: buildTerminalSize(24),
64
94
  }),
65
95
  'session.detach': SESSION_DEFAULTED,
66
96
  'session.input': SESSION_DEFAULTED.extend({
67
97
  d: buildDefaultedString(''),
68
98
  }),
69
99
  'session.resize': SESSION_DEFAULTED.extend({
70
- cols: buildDefaultedNumber(0),
71
- rows: buildDefaultedNumber(0),
100
+ cols: buildTerminalSize(0),
101
+ rows: buildTerminalSize(0),
72
102
  }).refine((v) => v.cols >= 1 && v.rows >= 1, {
73
103
  message: 'session.resize requires positive cols and rows',
74
104
  }),
@@ -78,8 +108,8 @@ export const REQUEST_PARAM_SCHEMAS = {
78
108
  prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
79
109
  }),
80
110
  'session.adopt': SESSION_DEFAULTED.extend({
81
- cols: buildDefaultedNumber(80),
82
- rows: buildDefaultedNumber(24),
111
+ cols: buildTerminalSize(80),
112
+ rows: buildTerminalSize(24),
83
113
  }),
84
114
  'permission.respond': z
85
115
  .object({
@@ -108,6 +138,10 @@ export const REQUEST_PARAM_SCHEMAS = {
108
138
  'session.message': SESSION_DEFAULTED.extend({
109
139
  from: buildDefaultedNonEmptyString('unknown'),
110
140
  text: buildDefaultedString(''),
141
+
142
+ // A retry carrying the same key replays the first send's message
143
+ // instead of sending another.
144
+ idempotencyKey: IDEMPOTENCY_KEY,
111
145
  }).refine((v) => v.text !== '', { message: 'session.message requires text' }),
112
146
  'session.tap': SESSION_DEFAULTED,
113
147
  'message.get': z
@@ -125,6 +159,22 @@ function buildDefaultedString(fallback: string) {
125
159
  return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
126
160
  }
127
161
 
162
+ // A terminal dimension: absent or not a number takes the fallback, and a
163
+ // number must be a whole number in range, since the terminal emulator and
164
+ // the PTY both assume one and a fractional size throws only after the
165
+ // process starts.
166
+ function buildTerminalSize(fallback: number) {
167
+ return z.preprocess(
168
+ (v) => (typeof v === 'number' ? v : undefined),
169
+ z
170
+ .number({ error: TERMINAL_SIZE_ERROR })
171
+ .int(TERMINAL_SIZE_ERROR)
172
+ .min(1, TERMINAL_SIZE_ERROR)
173
+ .max(4096, TERMINAL_SIZE_ERROR)
174
+ .default(fallback),
175
+ );
176
+ }
177
+
128
178
  function buildDefaultedNumber(fallback: number) {
129
179
  return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
130
180
  }
@@ -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'>;
@@ -0,0 +1,51 @@
1
+ import { isRecord } from './report';
2
+
3
+ interface PrincipalsConfig {
4
+ // The targets each principal may use, by principal; null when the config
5
+ // has no `principals` key.
6
+ readonly principals: ReadonlyMap<string, readonly string[]> | null;
7
+ readonly errors: readonly string[];
8
+ }
9
+
10
+ /**
11
+ * Reads the `principals` map: each principal's id to the names of the
12
+ * targets it may use. No `principals` key is null. Otherwise the config
13
+ * fails closed: a malformed map grants nothing to anyone, and a malformed
14
+ * entry grants nothing to its principal. Each problem is an error.
15
+ */
16
+ export function collectPrincipals(raw: unknown): PrincipalsConfig {
17
+ if (raw === undefined) {
18
+ return { principals: null, errors: [] };
19
+ }
20
+
21
+ if (!isRecord(raw) || Array.isArray(raw)) {
22
+ return {
23
+ principals: new Map(),
24
+ errors: ['principals must be an object of principal ids, so no principal gets a target'],
25
+ };
26
+ }
27
+
28
+ const principals = new Map<string, readonly string[]>();
29
+
30
+ const errors: string[] = [];
31
+
32
+ for (const [id, entry] of Object.entries(raw)) {
33
+ const targets = isRecord(entry) ? entry['targets'] : undefined;
34
+
35
+ if (
36
+ id === '' ||
37
+ !Array.isArray(targets) ||
38
+ !targets.every((target) => typeof target === 'string' && target !== '')
39
+ ) {
40
+ errors.push(
41
+ `principal ${JSON.stringify(id)} must be an object whose targets is an array of target names, so it gets no target`,
42
+ );
43
+
44
+ continue;
45
+ }
46
+
47
+ principals.set(id, targets);
48
+ }
49
+
50
+ return { principals, errors };
51
+ }
@@ -0,0 +1,144 @@
1
+ import { formatJSONKind } from './format-json-kind';
2
+ import { isRecord } from './report';
3
+
4
+ /**
5
+ * A named place sessions run: the execution provider kind that serves it,
6
+ * and the options that kind takes.
7
+ */
8
+ export interface TargetConfig {
9
+ readonly id: string;
10
+ readonly provider: string;
11
+ readonly options: Readonly<Record<string, unknown>>;
12
+ }
13
+
14
+ /**
15
+ * A config problem that leaves a target, or every target, unusable: the
16
+ * config file as a whole, the `targets` map, one entry of it, or
17
+ * `defaultTarget`.
18
+ */
19
+ export type TargetConfigError = TargetSectionError | ConfigFileError;
20
+
21
+ interface TargetSectionError {
22
+ readonly scope: 'targets' | 'target' | 'defaultTarget';
23
+
24
+ // The entry the problem is in; present only for an entry's problem.
25
+ readonly target?: string;
26
+ readonly problem: string;
27
+ }
28
+
29
+ /**
30
+ * A config file that exists but cannot be used, so no target in it can be
31
+ * trusted: `config_malformed` when its text is not JSON or its root is not
32
+ * an object, `config_unreadable` when reading it fails for any reason but
33
+ * its absence. `detail` holds the parse message, the root's kind, or the
34
+ * read's error code.
35
+ */
36
+ interface ConfigFileError {
37
+ readonly scope: 'config';
38
+ readonly problem: 'config_malformed' | 'config_unreadable';
39
+ readonly path: string;
40
+ readonly detail: string;
41
+ }
42
+
43
+ interface TargetsConfig {
44
+ // Every well-formed entry, in config order.
45
+ readonly targets: readonly TargetConfig[];
46
+
47
+ // The target a spawn without a target runs on, or null when the config
48
+ // gives none it can use.
49
+ readonly defaultTarget: string | null;
50
+ readonly errors: readonly TargetConfigError[];
51
+ }
52
+
53
+ // The target a config holds when it sets no targets of its own: the
54
+ // daemon's own machine.
55
+ const LOCAL_TARGET: TargetConfig = { id: 'local', provider: 'local-pty', options: {} };
56
+
57
+ /**
58
+ * Reads the `targets` map and `defaultTarget`. No `targets` key holds the
59
+ * one implicit `local` target as the default. Otherwise the config fails
60
+ * closed: a malformed map holds no targets, a malformed entry is left out,
61
+ * and a `defaultTarget` that is not a string or matches no well-formed
62
+ * entry leaves no default. Each problem is an error, so a spawn that
63
+ * resolves through it fails with that error instead of running on `local`.
64
+ * Without `defaultTarget`, the default is the `local` entry when the map
65
+ * holds a well-formed one, and none otherwise.
66
+ */
67
+ export function collectTargets(rawTargets: unknown, rawDefault: unknown): TargetsConfig {
68
+ if (rawTargets === undefined && rawDefault === undefined) {
69
+ return { targets: [LOCAL_TARGET], defaultTarget: 'local', errors: [] };
70
+ }
71
+
72
+ const collected = collectTargetEntries(rawTargets);
73
+
74
+ const ids = new Set(collected.targets.map((target) => target.id));
75
+
76
+ if (rawDefault === undefined) {
77
+ return {
78
+ targets: collected.targets,
79
+ defaultTarget: ids.has('local') ? 'local' : null,
80
+ errors: collected.errors,
81
+ };
82
+ }
83
+
84
+ if (typeof rawDefault === 'string' && ids.has(rawDefault)) {
85
+ return { targets: collected.targets, defaultTarget: rawDefault, errors: collected.errors };
86
+ }
87
+
88
+ // The problem gives the value's kind, never the value: a wrongly typed
89
+ // defaultTarget can hold anything the file holds.
90
+ const problem =
91
+ typeof rawDefault === 'string'
92
+ ? 'defaultTarget: matches no well-formed target in targets'
93
+ : `defaultTarget: expected a string, got ${formatJSONKind(rawDefault)}`;
94
+
95
+ return {
96
+ targets: collected.targets,
97
+ defaultTarget: null,
98
+ errors: [...collected.errors, { scope: 'defaultTarget', problem }],
99
+ };
100
+ }
101
+
102
+ interface TargetEntries {
103
+ readonly targets: readonly TargetConfig[];
104
+ readonly errors: readonly TargetSectionError[];
105
+ }
106
+
107
+ function collectTargetEntries(raw: unknown): TargetEntries {
108
+ // A defaultTarget without a targets map names one of the implicit targets.
109
+ if (raw === undefined) {
110
+ return { targets: [LOCAL_TARGET], errors: [] };
111
+ }
112
+
113
+ if (!isRecord(raw) || Array.isArray(raw) || Object.keys(raw).length === 0) {
114
+ return {
115
+ targets: [],
116
+ errors: [
117
+ { scope: 'targets', problem: 'targets must be a non-empty object of named targets' },
118
+ ],
119
+ };
120
+ }
121
+
122
+ const targets: TargetConfig[] = [];
123
+ const errors: TargetSectionError[] = [];
124
+
125
+ for (const [id, entry] of Object.entries(raw)) {
126
+ const provider = isRecord(entry) ? entry['provider'] : undefined;
127
+
128
+ if (id === '' || !isRecord(entry) || typeof provider !== 'string' || provider === '') {
129
+ errors.push({
130
+ scope: 'target',
131
+ target: id,
132
+ problem: `target ${JSON.stringify(id)} must be an object with a non-empty string provider`,
133
+ });
134
+
135
+ continue;
136
+ }
137
+
138
+ const { provider: _, ...options } = entry;
139
+
140
+ targets.push({ id, provider, options });
141
+ }
142
+
143
+ return { targets, errors };
144
+ }