@zgeoff/atc 2.12.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.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/index.ts +50 -1
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +4 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +97 -4
- package/src/daemon/daemon.ts +298 -41
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +9 -2
- package/src/mcp/require-daemon-features.ts +3 -0
- package/src/mcp/run-tool.ts +27 -8
- package/src/protocol/daemon-features.ts +11 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +66 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +76 -0
- package/src/store/state-store.ts +182 -21
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -44,10 +44,10 @@ export function startHeadlessTurn(
|
|
|
44
44
|
cwd: s.cwd,
|
|
45
45
|
prompt,
|
|
46
46
|
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
47
|
-
permissionMode: 'auto',
|
|
48
47
|
sessionID,
|
|
49
48
|
...(s.model === undefined ? {} : { model: s.model }),
|
|
50
49
|
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
50
|
+
...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
onOutput: (text) => {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
3
|
+
import type { MessageID } from '../shared/message-id';
|
|
4
|
+
import type { SessionID } from '../shared/session-id';
|
|
5
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
6
|
+
import type { MessageRecord } from '../store/message-record';
|
|
7
|
+
import type { TapClient } from './daemon-connection';
|
|
8
|
+
import type { HarnessRelay } from './execution-provider';
|
|
9
|
+
import type { HookEvent } from './hooks';
|
|
10
|
+
import { isBindingCurrent } from './is-binding-current';
|
|
11
|
+
import type { BridgeBinding } from './is-binding-current';
|
|
12
|
+
import { parseHookLine } from './parse-hook-line';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The fields of a live session the bridge checks its binding against and
|
|
16
|
+
* reads the status from.
|
|
17
|
+
*/
|
|
18
|
+
export interface BridgeSession {
|
|
19
|
+
readonly id: SessionID;
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly targetIdentity: string;
|
|
22
|
+
readonly hostKey: SessionID;
|
|
23
|
+
readonly bridgeEpoch: number;
|
|
24
|
+
readonly state: string;
|
|
25
|
+
readonly lastMsg: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the bridge needs from the daemon. Each operation acts on the one
|
|
30
|
+
* session the caller names, which is always the bridge's own.
|
|
31
|
+
*/
|
|
32
|
+
export interface BridgeContext {
|
|
33
|
+
readonly findSession: (sessionID: SessionID) => BridgeSession | undefined;
|
|
34
|
+
readonly applyHookEvent: (e: HookEvent) => void;
|
|
35
|
+
|
|
36
|
+
// Applies a report under the id its reporter gave it, and settles once
|
|
37
|
+
// the report is recorded; false when the payload is no report.
|
|
38
|
+
readonly applyReport: (
|
|
39
|
+
sessionID: SessionID,
|
|
40
|
+
payload: Readonly<Record<string, unknown>>,
|
|
41
|
+
reportID: string,
|
|
42
|
+
) => Promise<boolean>;
|
|
43
|
+
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
44
|
+
readonly ackMessage: (
|
|
45
|
+
client: TapClient,
|
|
46
|
+
sessionID: SessionID,
|
|
47
|
+
messageID: MessageID,
|
|
48
|
+
) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
|
|
49
|
+
readonly detachTap: (client: TapClient) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const REQUEST_SCHEMA = z.looseObject({ v: z.literal(1), id: z.string().min(1), op: z.string() });
|
|
53
|
+
|
|
54
|
+
const REPORT_SCHEMA = z.looseObject({
|
|
55
|
+
reportID: z.string().min(1).max(128),
|
|
56
|
+
payload: z.record(z.string(), z.unknown()),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const ACK_SCHEMA = z.looseObject({ message: z.string().min(1) });
|
|
60
|
+
|
|
61
|
+
type BridgeRefusal = 'forbidden' | 'stale_binding' | 'not_tapping' | 'unknown_message';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serves one connection a remote harness's processes opened to the daemon:
|
|
65
|
+
* the session bridge. A line without an `op` is a hook line, as the local
|
|
66
|
+
* reporter socket takes it. A request line `{ v: 1, id, op }` gets one
|
|
67
|
+
* answer line `{ id, ok }`, and the bridge's own inbox events go out as
|
|
68
|
+
* protocol event lines.
|
|
69
|
+
*
|
|
70
|
+
* The bridge takes hooks, reports, its session's tap and acks, and its
|
|
71
|
+
* session's own status, for the one session it was bound to and nothing
|
|
72
|
+
* else: an unknown op, a malformed request, or a report or hook for any
|
|
73
|
+
* other session answers `forbidden` and closes the connection. Every line
|
|
74
|
+
* checks the binding against the live session first, and one that no
|
|
75
|
+
* longer matches answers `stale_binding` and closes.
|
|
76
|
+
*
|
|
77
|
+
* Trust: the binding holds no secret. A host serves the bridge's socket
|
|
78
|
+
* inside itself alone, so reaching the socket is the proof of being inside
|
|
79
|
+
* that host. One host is one trust domain: a sub-session in its parent's
|
|
80
|
+
* host runs as the same user and can reach the parent's socket too. The
|
|
81
|
+
* per-session socket and binding stop processes in the host from crossing
|
|
82
|
+
* sessions by accident, never a hostile process inside the same host.
|
|
83
|
+
*/
|
|
84
|
+
export function startSessionBridge(
|
|
85
|
+
relay: HarnessRelay,
|
|
86
|
+
binding: Readonly<BridgeBinding>,
|
|
87
|
+
ctx: BridgeContext,
|
|
88
|
+
): void {
|
|
89
|
+
let closed = false;
|
|
90
|
+
let writing: Promise<void> = Promise.resolve();
|
|
91
|
+
|
|
92
|
+
// Lines go out one at a time, in the order they were written, and none
|
|
93
|
+
// after the bridge closed.
|
|
94
|
+
const writeLine = async (value: Readonly<Record<string, unknown>>) => {
|
|
95
|
+
const line = JSON.stringify(value);
|
|
96
|
+
const previous = writing;
|
|
97
|
+
|
|
98
|
+
writing = (async () => {
|
|
99
|
+
await previous;
|
|
100
|
+
|
|
101
|
+
if (!closed) {
|
|
102
|
+
try {
|
|
103
|
+
await relay.writeLine(line);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
await writing;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const stopBridge = () => {
|
|
112
|
+
if (closed) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
closed = true;
|
|
117
|
+
|
|
118
|
+
ctx.detachTap(tap);
|
|
119
|
+
relay.close();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Answers a refusal, then closes once the answer has gone out.
|
|
123
|
+
const answerRefusal = async (id: string | null, code: BridgeRefusal) => {
|
|
124
|
+
await writeLine({ id, ok: false, code });
|
|
125
|
+
|
|
126
|
+
stopBridge();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const tap: TapClient = {
|
|
130
|
+
sendEvent: (event: EventMsg) => {
|
|
131
|
+
void (async () => {
|
|
132
|
+
await writeLine(event);
|
|
133
|
+
|
|
134
|
+
if (event.ev === 'InboxClosed') {
|
|
135
|
+
stopBridge();
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const isCurrent = () => isBindingCurrent(binding, ctx.findSession(binding.sessionID));
|
|
142
|
+
|
|
143
|
+
const applyRequest = async (
|
|
144
|
+
request: Readonly<Record<string, unknown>>,
|
|
145
|
+
id: string,
|
|
146
|
+
op: string,
|
|
147
|
+
) => {
|
|
148
|
+
if (op === 'report') {
|
|
149
|
+
const parsed = REPORT_SCHEMA.safeParse(request);
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
!parsed.success ||
|
|
153
|
+
!(await ctx.applyReport(binding.sessionID, parsed.data.payload, parsed.data.reportID))
|
|
154
|
+
) {
|
|
155
|
+
void answerRefusal(id, 'forbidden');
|
|
156
|
+
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
void writeLine({ id, ok: true });
|
|
161
|
+
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (op === 'tap.open') {
|
|
166
|
+
if (ctx.attachTap(tap, binding.sessionID) !== 'ok') {
|
|
167
|
+
void answerRefusal(id, 'forbidden');
|
|
168
|
+
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
void writeLine({ id, ok: true });
|
|
173
|
+
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (op === 'tap.ack') {
|
|
178
|
+
const parsed = ACK_SCHEMA.safeParse(request);
|
|
179
|
+
|
|
180
|
+
if (!parsed.success) {
|
|
181
|
+
void answerRefusal(id, 'forbidden');
|
|
182
|
+
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const acked = await ctx.ackMessage(tap, binding.sessionID, toMessageID(parsed.data.message));
|
|
187
|
+
|
|
188
|
+
if (acked === 'not_tapping' || acked === 'unknown') {
|
|
189
|
+
const code: BridgeRefusal = acked === 'unknown' ? 'unknown_message' : 'not_tapping';
|
|
190
|
+
|
|
191
|
+
void writeLine({ id, ok: false, code });
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
void writeLine({ id, ok: true, status: acked.status });
|
|
197
|
+
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (op === 'status.read') {
|
|
202
|
+
const s = ctx.findSession(binding.sessionID);
|
|
203
|
+
|
|
204
|
+
void writeLine({ id, ok: true, state: s?.state ?? 'exited', lastMsg: s?.lastMsg ?? '' });
|
|
205
|
+
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
void answerRefusal(id, 'forbidden');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Lines are applied one at a time, in the order they arrived.
|
|
213
|
+
let applying: Promise<void> = Promise.resolve();
|
|
214
|
+
|
|
215
|
+
const applyLine = async (line: string) => {
|
|
216
|
+
if (closed) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let parsed: unknown;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(line);
|
|
224
|
+
} catch {
|
|
225
|
+
void answerRefusal(null, 'forbidden');
|
|
226
|
+
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
231
|
+
void answerRefusal(null, 'forbidden');
|
|
232
|
+
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const record: Readonly<Record<string, unknown>> = Object.fromEntries(Object.entries(parsed));
|
|
237
|
+
|
|
238
|
+
if (!('op' in record)) {
|
|
239
|
+
const e = parseHookLine(line);
|
|
240
|
+
|
|
241
|
+
if (!isCurrent()) {
|
|
242
|
+
void answerRefusal(null, 'stale_binding');
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (e === null || e.atcId !== binding.sessionID) {
|
|
248
|
+
void answerRefusal(null, 'forbidden');
|
|
249
|
+
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
ctx.applyHookEvent(e);
|
|
254
|
+
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const request = REQUEST_SCHEMA.safeParse(record);
|
|
259
|
+
|
|
260
|
+
if (!request.success) {
|
|
261
|
+
const id = typeof record['id'] === 'string' ? record['id'] : null;
|
|
262
|
+
|
|
263
|
+
void answerRefusal(id, 'forbidden');
|
|
264
|
+
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!isCurrent()) {
|
|
269
|
+
void answerRefusal(request.data.id, 'stale_binding');
|
|
270
|
+
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await applyRequest(record, request.data.id, request.data.op);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
relay.onLine((line) => {
|
|
278
|
+
const previous = applying;
|
|
279
|
+
|
|
280
|
+
applying = (async () => {
|
|
281
|
+
await previous;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await applyLine(line);
|
|
285
|
+
} catch {}
|
|
286
|
+
})();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
relay.onClose(() => {
|
|
290
|
+
closed = true;
|
|
291
|
+
|
|
292
|
+
ctx.detachTap(tap);
|
|
293
|
+
});
|
|
294
|
+
}
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -42,6 +42,9 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
42
42
|
target: SPAWN_SCHEMA.shape.target.describe(
|
|
43
43
|
'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
|
|
44
44
|
),
|
|
45
|
+
workspace: SPAWN_SCHEMA.shape.workspace.describe(
|
|
46
|
+
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, refusing uncommitted changes unless allowDirty is 'warn'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
|
|
47
|
+
),
|
|
45
48
|
detached: z
|
|
46
49
|
.boolean()
|
|
47
50
|
.optional()
|
|
@@ -353,6 +356,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
353
356
|
effort: 'spawn.options',
|
|
354
357
|
idempotencyKey: 'spawn.idempotency',
|
|
355
358
|
target: 'spawn.target',
|
|
359
|
+
workspace: 'spawn.workspace',
|
|
356
360
|
},
|
|
357
361
|
},
|
|
358
362
|
},
|
|
@@ -361,12 +365,15 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
361
365
|
annotations: AGENT_FACING_DESTRUCTIVE,
|
|
362
366
|
scope: 'spawn',
|
|
363
367
|
description:
|
|
364
|
-
|
|
368
|
+
"Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
|
|
365
369
|
inputSchema: {
|
|
366
370
|
type: 'object',
|
|
367
371
|
properties: {
|
|
368
372
|
session: { type: 'string', description: 'The atc session id' },
|
|
369
|
-
text: {
|
|
373
|
+
text: {
|
|
374
|
+
type: 'string',
|
|
375
|
+
description: "The line to submit; atc adds the submit key the session's agent expects",
|
|
376
|
+
},
|
|
370
377
|
},
|
|
371
378
|
required: ['session', 'text'],
|
|
372
379
|
additionalProperties: false,
|
|
@@ -10,11 +10,14 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
10
10
|
'message.idempotency': "atc_session_message's idempotencyKey",
|
|
11
11
|
'message.turn': "atc_message_get's turn and answeredWith",
|
|
12
12
|
'message.wait': "atc_message_get's waitMs",
|
|
13
|
+
'session.forget': 'session.forget',
|
|
13
14
|
'session.locator': "a session's locator",
|
|
15
|
+
'session.submit': 'atc_session_input',
|
|
14
16
|
'spawn.idempotency': "atc_session_spawn's idempotencyKey",
|
|
15
17
|
'spawn.options': "atc_session_spawn's model and effort",
|
|
16
18
|
'spawn.target': "atc_session_spawn's target",
|
|
17
19
|
'request.principal': 'the target limits of a remote MCP client',
|
|
20
|
+
'spawn.workspace': "atc_session_spawn's workspace",
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -46,12 +46,13 @@ export function runTool(
|
|
|
46
46
|
...(args['effort'] === undefined ? {} : { effort: args['effort'] }),
|
|
47
47
|
...(key === undefined ? {} : { idempotencyKey: key }),
|
|
48
48
|
...(args['target'] === undefined ? {} : { target: args['target'] }),
|
|
49
|
+
...(args['workspace'] === undefined ? {} : { workspace: args['workspace'] }),
|
|
49
50
|
cols: 100,
|
|
50
51
|
rows: 30,
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
// A model, effort, key, or
|
|
54
|
-
// runs on every connection the spawn rides.
|
|
54
|
+
// A model, effort, key, target, or workspace needs a daemon that takes
|
|
55
|
+
// them; the check runs on every connection the spawn rides.
|
|
55
56
|
const optionFeatures: readonly DaemonFeature[] =
|
|
56
57
|
args['model'] === undefined && args['effort'] === undefined ? [] : ['spawn.options'];
|
|
57
58
|
|
|
@@ -60,20 +61,38 @@ export function runTool(
|
|
|
60
61
|
const targetFeatures: readonly DaemonFeature[] =
|
|
61
62
|
args['target'] === undefined ? [] : ['spawn.target'];
|
|
62
63
|
|
|
63
|
-
const
|
|
64
|
+
const workspaceFeatures: readonly DaemonFeature[] =
|
|
65
|
+
args['workspace'] === undefined ? [] : ['spawn.workspace'];
|
|
66
|
+
|
|
67
|
+
const required = [...optionFeatures, ...keyFeatures, ...targetFeatures, ...workspaceFeatures];
|
|
64
68
|
|
|
65
69
|
const ok =
|
|
66
70
|
nested && ctx.callerSessionID !== null
|
|
67
71
|
? await sendNestedSpawn(caller, params, ctx.callerSessionID, required)
|
|
68
72
|
: await caller.sendRequest('session.spawn', params, required);
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
// A spawn whose workspace left changes behind returns its warnings
|
|
75
|
+
// beside the session's own fields.
|
|
76
|
+
const warnings = ok['warnings'];
|
|
77
|
+
|
|
78
|
+
const session =
|
|
79
|
+
warnings === undefined || !isRecord(ok['session'])
|
|
80
|
+
? ok['session']
|
|
81
|
+
: { ...ok['session'], warnings };
|
|
82
|
+
|
|
83
|
+
return buildObjectResult(session);
|
|
71
84
|
})
|
|
72
85
|
.with('atc_session_input', async () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
// An older daemon would take the line as raw input, which some agents
|
|
87
|
+
// never submit.
|
|
88
|
+
await caller.sendRequest(
|
|
89
|
+
'session.submit',
|
|
90
|
+
{
|
|
91
|
+
session: args['session'],
|
|
92
|
+
text: typeof args['text'] === 'string' ? args['text'] : '',
|
|
93
|
+
},
|
|
94
|
+
['session.submit'],
|
|
95
|
+
);
|
|
77
96
|
|
|
78
97
|
return { text: 'sent', structured: null };
|
|
79
98
|
})
|
|
@@ -42,6 +42,17 @@ export const DAEMON_FEATURES = [
|
|
|
42
42
|
// A request takes `as`, the principal it acts as, and `daemon.hello`
|
|
43
43
|
// takes `principal`, the principal the whole connection acts as.
|
|
44
44
|
'request.principal',
|
|
45
|
+
|
|
46
|
+
// `session.spawn` takes `workspace`, and a session descriptor holds the
|
|
47
|
+
// `workspace` its checkout was materialized from.
|
|
48
|
+
'spawn.workspace',
|
|
49
|
+
|
|
50
|
+
// `session.forget` exists, and a kill of a session asleep on a target that
|
|
51
|
+
// can destroy its host answers `confirmation_required`.
|
|
52
|
+
'session.forget',
|
|
53
|
+
|
|
54
|
+
// `session.submit` exists.
|
|
55
|
+
'session.submit',
|
|
45
56
|
] as const;
|
|
46
57
|
|
|
47
58
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -26,6 +26,29 @@ const ERROR_CODES = [
|
|
|
26
26
|
'target_changed',
|
|
27
27
|
'target_config_invalid',
|
|
28
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',
|
|
29
52
|
'already_answered',
|
|
30
53
|
'too_slow',
|
|
31
54
|
'stale_epoch',
|
|
@@ -23,6 +23,55 @@ const IDEMPOTENCY_KEY = z
|
|
|
23
23
|
.max(200, 'idempotencyKey must be at most 200 characters')
|
|
24
24
|
.optional();
|
|
25
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
|
+
|
|
26
75
|
// The refusal of a terminal size outside the range a terminal takes.
|
|
27
76
|
const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
|
|
28
77
|
|
|
@@ -75,6 +124,11 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
75
124
|
.min(1, 'session.spawn target must be a non-empty target id')
|
|
76
125
|
.optional(),
|
|
77
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
|
+
|
|
78
132
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
79
133
|
// top-level session.
|
|
80
134
|
parent: z.preprocess(
|
|
@@ -84,6 +138,15 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
84
138
|
}),
|
|
85
139
|
'session.kill': SESSION_DEFAULTED,
|
|
86
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
|
+
}),
|
|
87
150
|
'session.update': SESSION_DEFAULTED.extend({
|
|
88
151
|
name: buildOptionalString(),
|
|
89
152
|
pinned: buildOptionalBoolean(),
|
|
@@ -96,6 +159,9 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
96
159
|
'session.input': SESSION_DEFAULTED.extend({
|
|
97
160
|
d: buildDefaultedString(''),
|
|
98
161
|
}),
|
|
162
|
+
'session.submit': SESSION_DEFAULTED.extend({
|
|
163
|
+
text: buildDefaultedString(''),
|
|
164
|
+
}),
|
|
99
165
|
'session.resize': SESSION_DEFAULTED.extend({
|
|
100
166
|
cols: buildTerminalSize(0),
|
|
101
167
|
rows: buildTerminalSize(0),
|
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.
|
|
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,
|