@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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +51 -0
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +49 -2
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +43 -2
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +45 -2
- package/src/agents/grok-adapter.ts +19 -1
- package/src/cli.ts +18 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +331 -95
- package/src/daemon/daemon.ts +452 -75
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +282 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +98 -8
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +7 -0
- package/src/mcp/run-tool.ts +63 -11
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +24 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +60 -10
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +254 -5
- package/src/store/state-store.ts +433 -31
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
package/src/mcp/run-tool.ts
CHANGED
|
@@ -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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 (
|
|
190
|
-
|
|
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
|
-
|
|
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];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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 =
|
|
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) ||
|
|
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
|
-
...(
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
31
|
-
rows:
|
|
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:
|
|
38
|
-
rows:
|
|
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:
|
|
63
|
-
rows:
|
|
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:
|
|
71
|
-
rows:
|
|
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:
|
|
82
|
-
rows:
|
|
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
|
|
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
|
+
}
|