@zgeoff/atc 2.10.4 → 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/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- 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 +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- 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 +371 -99
- package/src/daemon/daemon.ts +537 -90
- 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-report.ts +29 -3
- 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 +287 -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 +40 -6
- 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 +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- 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/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- 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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DaemonFeature } from './daemon-features';
|
|
2
|
+
import { DAEMON_FEATURES } from './daemon-features';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the features a `daemon.hello` answer announces. A missing or
|
|
6
|
+
* malformed list, which an older daemon sends, is no features, and a name
|
|
7
|
+
* this build does not know is dropped.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDaemonFeatures(
|
|
10
|
+
hello: Readonly<Record<string, unknown>>,
|
|
11
|
+
): ReadonlySet<DaemonFeature> {
|
|
12
|
+
const announced = hello['features'];
|
|
13
|
+
|
|
14
|
+
if (!Array.isArray(announced)) {
|
|
15
|
+
return new Set();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Set(DAEMON_FEATURES.filter((feature) => announced.includes(feature)));
|
|
19
|
+
}
|
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,25 +16,40 @@ 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({}),
|
|
25
39
|
'session.list': z.object({}),
|
|
26
40
|
'dirs.list': z.object({}),
|
|
41
|
+
'agents.list': z.object({}),
|
|
27
42
|
'fleet.list': z.object({}),
|
|
28
43
|
'fleet.restore': z.object({
|
|
29
|
-
cols:
|
|
30
|
-
rows:
|
|
44
|
+
cols: buildTerminalSize(80),
|
|
45
|
+
rows: buildTerminalSize(24),
|
|
31
46
|
}),
|
|
32
47
|
'session.spawn': z.object({
|
|
33
48
|
cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
|
|
34
49
|
name: buildDefaultedString(''),
|
|
35
50
|
prompt: buildDefaultedString(''),
|
|
36
|
-
cols:
|
|
37
|
-
rows:
|
|
51
|
+
cols: buildTerminalSize(80),
|
|
52
|
+
rows: buildTerminalSize(24),
|
|
38
53
|
|
|
39
54
|
resume: buildDefaultedBooleanOrString(false).transform((v) =>
|
|
40
55
|
typeof v === 'string' ? toAgentSessionID(v) : v,
|
|
@@ -44,6 +59,22 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
44
59
|
.min(1, 'session.spawn agent must be a non-empty agent id')
|
|
45
60
|
.optional(),
|
|
46
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
|
+
|
|
47
78
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
48
79
|
// top-level session.
|
|
49
80
|
parent: z.preprocess(
|
|
@@ -58,16 +89,16 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
58
89
|
pinned: buildOptionalBoolean(),
|
|
59
90
|
}),
|
|
60
91
|
'session.attach': SESSION_DEFAULTED.extend({
|
|
61
|
-
cols:
|
|
62
|
-
rows:
|
|
92
|
+
cols: buildTerminalSize(80),
|
|
93
|
+
rows: buildTerminalSize(24),
|
|
63
94
|
}),
|
|
64
95
|
'session.detach': SESSION_DEFAULTED,
|
|
65
96
|
'session.input': SESSION_DEFAULTED.extend({
|
|
66
97
|
d: buildDefaultedString(''),
|
|
67
98
|
}),
|
|
68
99
|
'session.resize': SESSION_DEFAULTED.extend({
|
|
69
|
-
cols:
|
|
70
|
-
rows:
|
|
100
|
+
cols: buildTerminalSize(0),
|
|
101
|
+
rows: buildTerminalSize(0),
|
|
71
102
|
}).refine((v) => v.cols >= 1 && v.rows >= 1, {
|
|
72
103
|
message: 'session.resize requires positive cols and rows',
|
|
73
104
|
}),
|
|
@@ -77,8 +108,8 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
77
108
|
prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
|
|
78
109
|
}),
|
|
79
110
|
'session.adopt': SESSION_DEFAULTED.extend({
|
|
80
|
-
cols:
|
|
81
|
-
rows:
|
|
111
|
+
cols: buildTerminalSize(80),
|
|
112
|
+
rows: buildTerminalSize(24),
|
|
82
113
|
}),
|
|
83
114
|
'permission.respond': z
|
|
84
115
|
.object({
|
|
@@ -96,15 +127,28 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
96
127
|
'events.read': z.object({
|
|
97
128
|
cursor: buildOptionalCursor(),
|
|
98
129
|
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
99
|
-
waitMs:
|
|
130
|
+
waitMs: buildDefaultedWait(),
|
|
131
|
+
|
|
132
|
+
// Limits the read to one session's events; absent or empty reads the whole fleet.
|
|
133
|
+
session: z.preprocess(
|
|
134
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
135
|
+
z.string().transform(toSessionID).optional(),
|
|
136
|
+
),
|
|
100
137
|
}),
|
|
101
138
|
'session.message': SESSION_DEFAULTED.extend({
|
|
102
139
|
from: buildDefaultedNonEmptyString('unknown'),
|
|
103
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,
|
|
104
145
|
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
105
146
|
'session.tap': SESSION_DEFAULTED,
|
|
106
147
|
'message.get': z
|
|
107
|
-
.object({
|
|
148
|
+
.object({
|
|
149
|
+
message: buildDefaultedString('').transform(toMessageID),
|
|
150
|
+
waitMs: buildDefaultedWait(),
|
|
151
|
+
})
|
|
108
152
|
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
109
153
|
'message.ack': SESSION_DEFAULTED.extend({
|
|
110
154
|
message: buildDefaultedString('').transform(toMessageID),
|
|
@@ -115,10 +159,32 @@ function buildDefaultedString(fallback: string) {
|
|
|
115
159
|
return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
|
|
116
160
|
}
|
|
117
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
|
+
|
|
118
178
|
function buildDefaultedNumber(fallback: number) {
|
|
119
179
|
return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
|
|
120
180
|
}
|
|
121
181
|
|
|
182
|
+
// How long a read may hold its request open, in milliseconds: 0 when absent,
|
|
183
|
+
// clamped to 0–30000.
|
|
184
|
+
function buildDefaultedWait() {
|
|
185
|
+
return buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000));
|
|
186
|
+
}
|
|
187
|
+
|
|
122
188
|
function buildDefaultedBooleanOrString(fallback: boolean | string) {
|
|
123
189
|
return z.preprocess(
|
|
124
190
|
(v) => (typeof v === 'boolean' || typeof v === 'string' ? v : undefined),
|
package/src/report.ts
CHANGED
|
@@ -3,13 +3,21 @@ import { REPORT_KINDS } from './shared/report-kinds';
|
|
|
3
3
|
|
|
4
4
|
interface ReportOptions {
|
|
5
5
|
readonly message: string;
|
|
6
|
+
|
|
7
|
+
// Comma-separated ids of every message one turn answered, reported together.
|
|
8
|
+
readonly messages: string;
|
|
6
9
|
readonly label: string;
|
|
10
|
+
|
|
11
|
+
// The turn whose final reply an `answered` report carries; empty when unknown.
|
|
12
|
+
readonly turn: string;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
|
|
11
17
|
* atc socket as a Report envelope of the given kind. An `answered` report
|
|
12
|
-
* carries stdin as the final
|
|
18
|
+
* carries stdin as the final reply of the turn that carried the given
|
|
19
|
+
* message, or every given message at once, plus that turn's id when one is
|
|
20
|
+
* given; a `note` carries
|
|
13
21
|
* it as text for the user under the given label, `progress` when none is
|
|
14
22
|
* given. Always exits 0 so it never blocks the session it reports on.
|
|
15
23
|
*/
|
|
@@ -44,9 +52,18 @@ function buildReportPayload(
|
|
|
44
52
|
kind: string,
|
|
45
53
|
options: ReportOptions,
|
|
46
54
|
stdin: string,
|
|
47
|
-
): Record<string, string> | null {
|
|
55
|
+
): Record<string, string | readonly string[]> | null {
|
|
48
56
|
if (kind === 'answered') {
|
|
49
|
-
|
|
57
|
+
const messages = options.messages.split(',').filter((id) => id !== '');
|
|
58
|
+
const turn = options.turn === '' ? {} : { turn: options.turn };
|
|
59
|
+
|
|
60
|
+
if (messages.length > 0) {
|
|
61
|
+
return { kind, messages, answer: stdin, ...turn };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return options.message === ''
|
|
65
|
+
? null
|
|
66
|
+
: { kind, message: options.message, answer: stdin, ...turn };
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
if (kind === 'note') {
|
|
@@ -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
|
+
}
|