@zgeoff/atc 2.12.0 → 2.14.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 +12 -11
- 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/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- 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 +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -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 +134 -4
- package/src/daemon/daemon.ts +306 -42
- 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 +574 -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 +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -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 +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -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
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
3
|
+
import { buildTarArchive } from './build-tar-archive';
|
|
4
|
+
import type {
|
|
5
|
+
CommandResult,
|
|
6
|
+
CommandSpec,
|
|
7
|
+
ExecutionCapabilities,
|
|
8
|
+
ExecutionProvider,
|
|
9
|
+
GuestLayout,
|
|
10
|
+
HarnessHandle,
|
|
11
|
+
HarnessRelay,
|
|
12
|
+
HarnessSpec,
|
|
13
|
+
HostRequest,
|
|
14
|
+
} from './execution-provider';
|
|
15
|
+
import { ImpHarness } from './imp-harness';
|
|
16
|
+
import type { ImpPort, ImpRelayConnection } from './imp-port';
|
|
17
|
+
import { ImpPortError } from './imp-port-error';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The options an `imp` target takes beside its provider: the image a new
|
|
21
|
+
* imp boots, the memory it gets, the folder inside each imp that atc's
|
|
22
|
+
* files go under, and the path of an atc binary already installed in the
|
|
23
|
+
* image. None holds a credential.
|
|
24
|
+
*/
|
|
25
|
+
export interface ImpTargetOptions {
|
|
26
|
+
readonly image?: string;
|
|
27
|
+
readonly memoryMib?: number;
|
|
28
|
+
readonly guestDir?: string;
|
|
29
|
+
readonly guestATC?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ImpProviderOptions {
|
|
33
|
+
// How long each lease the daemon takes lasts before a renewal, in seconds.
|
|
34
|
+
readonly leaseSeconds?: number;
|
|
35
|
+
|
|
36
|
+
// The wait before each reconnect to a harness whose connection ended
|
|
37
|
+
// without an exit, in milliseconds.
|
|
38
|
+
readonly reconnectDelaysMs?: readonly number[];
|
|
39
|
+
|
|
40
|
+
// The Linux atc binary on the daemon's machine that the provider copies
|
|
41
|
+
// into an imp without one, or null for none. A compiled daemon on Linux
|
|
42
|
+
// copies itself; one run from source has no binary to copy.
|
|
43
|
+
readonly atcBinary?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The folder inside an imp that atc's files go under when the target sets
|
|
47
|
+
// none.
|
|
48
|
+
const GUEST_DIR = '/tmp/atc';
|
|
49
|
+
|
|
50
|
+
// impd takes a lease of 10 to 3600 seconds; the daemon renews at a third of it.
|
|
51
|
+
const LEASE_SECONDS = 600;
|
|
52
|
+
|
|
53
|
+
// About 15 seconds of reconnects before a harness counts as ended.
|
|
54
|
+
const RECONNECT_DELAYS_MS: readonly number[] = [250, 1000, 2000, 4000, 8000];
|
|
55
|
+
|
|
56
|
+
// The PATH a guest harness runs with: the guest's standard one, never the
|
|
57
|
+
// daemon's.
|
|
58
|
+
const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `imp` provider: one imp, a VM that impd hosts, per top-level session,
|
|
62
|
+
* and every sub-session on the same target in its parent's imp. The daemon
|
|
63
|
+
* holds each imp it uses with a lease labelled `atc-<daemonID>`, renews it
|
|
64
|
+
* while a harness runs there, and gives it back when the last harness ends
|
|
65
|
+
* or before it puts the imp to sleep; it never forces a sleep. Each harness
|
|
66
|
+
* is an imp session, and the daemon is its one attacher.
|
|
67
|
+
*/
|
|
68
|
+
export class ImpProvider implements ExecutionProvider {
|
|
69
|
+
readonly kind = 'imp';
|
|
70
|
+
|
|
71
|
+
readonly remote = true;
|
|
72
|
+
|
|
73
|
+
readonly capabilities: ExecutionCapabilities = {
|
|
74
|
+
spawn: true,
|
|
75
|
+
attach: true,
|
|
76
|
+
input: true,
|
|
77
|
+
resize: true,
|
|
78
|
+
kill: true,
|
|
79
|
+
transfer: true,
|
|
80
|
+
run: true,
|
|
81
|
+
headless: false,
|
|
82
|
+
suspend: true,
|
|
83
|
+
destroy: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
readonly guest: GuestLayout;
|
|
87
|
+
|
|
88
|
+
private readonly port: ImpPort;
|
|
89
|
+
|
|
90
|
+
private readonly target: ImpTargetOptions;
|
|
91
|
+
|
|
92
|
+
private readonly leaseSeconds: number;
|
|
93
|
+
|
|
94
|
+
private readonly reconnectDelaysMs: readonly number[];
|
|
95
|
+
|
|
96
|
+
private readonly hosts = new Map<string, ImpHost>();
|
|
97
|
+
|
|
98
|
+
private readonly atcBinary: string | null;
|
|
99
|
+
|
|
100
|
+
// Whether impd carries output offsets, once a prepare has read its
|
|
101
|
+
// features; an impd without the flag carries none.
|
|
102
|
+
private offsets = false;
|
|
103
|
+
|
|
104
|
+
// The lease label of the daemon this provider serves, once a prepare
|
|
105
|
+
// names it.
|
|
106
|
+
private label: string | null = null;
|
|
107
|
+
|
|
108
|
+
constructor(port: ImpPort, target: ImpTargetOptions, options: ImpProviderOptions = {}) {
|
|
109
|
+
this.port = port;
|
|
110
|
+
this.target = target;
|
|
111
|
+
this.leaseSeconds = options.leaseSeconds ?? LEASE_SECONDS;
|
|
112
|
+
this.reconnectDelaysMs = options.reconnectDelaysMs ?? RECONNECT_DELAYS_MS;
|
|
113
|
+
|
|
114
|
+
this.atcBinary =
|
|
115
|
+
target.guestATC === undefined ? (options.atcBinary ?? findOwnLinuxBinary()) : null;
|
|
116
|
+
|
|
117
|
+
const dir = target.guestDir ?? GUEST_DIR;
|
|
118
|
+
|
|
119
|
+
this.guest = {
|
|
120
|
+
dir,
|
|
121
|
+
atc: target.guestATC ?? (this.atcBinary === null ? null : `${dir}/bin/atc`),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Creates the host's imp when impd holds none, then takes the daemon's
|
|
126
|
+
// lease, which boots or wakes the imp.
|
|
127
|
+
readonly prepareHost = async (request: HostRequest): Promise<void> => {
|
|
128
|
+
const name = buildImpName(request.host);
|
|
129
|
+
const label = `atc-${request.daemonID}`;
|
|
130
|
+
|
|
131
|
+
this.label = label;
|
|
132
|
+
|
|
133
|
+
// A lease a running harness here already relies on stays held through a
|
|
134
|
+
// failed prepare; only one this prepare took is given back.
|
|
135
|
+
const held = this.hosts.get(request.host);
|
|
136
|
+
const holdsLease = held !== undefined && (held.harnesses > 0 || held.renewTimer !== null);
|
|
137
|
+
let created = false;
|
|
138
|
+
let leased = false;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const features = await this.port.readFeatures();
|
|
142
|
+
|
|
143
|
+
this.offsets = features.sessionOffsets;
|
|
144
|
+
|
|
145
|
+
const existing = await this.port.readImp(name);
|
|
146
|
+
|
|
147
|
+
if (existing === null) {
|
|
148
|
+
await this.port.createImp({
|
|
149
|
+
name,
|
|
150
|
+
...(this.target.image === undefined ? {} : { image: this.target.image }),
|
|
151
|
+
...(this.target.memoryMib === undefined ? {} : { memoryMib: this.target.memoryMib }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
created = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await this.port.acquireLease(name, label, this.leaseSeconds);
|
|
158
|
+
|
|
159
|
+
leased = !holdsLease;
|
|
160
|
+
|
|
161
|
+
await this.setupGuest(name, request.installATC === true);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await this.tryUndoPrepare(name, label, created, leased);
|
|
164
|
+
|
|
165
|
+
throw toHostRefusal(error, name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const host = this.hosts.get(request.host) ?? {
|
|
169
|
+
name,
|
|
170
|
+
harnesses: 0,
|
|
171
|
+
renewTimer: null,
|
|
172
|
+
suspending: false,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
host.suspending = false;
|
|
176
|
+
|
|
177
|
+
this.hosts.set(request.host, host);
|
|
178
|
+
this.startRenewal(host, label);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
|
|
182
|
+
const host = this.hosts.get(spec.host) ?? {
|
|
183
|
+
name: buildImpName(spec.host),
|
|
184
|
+
harnesses: 0,
|
|
185
|
+
renewTimer: null,
|
|
186
|
+
suspending: false,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.hosts.set(spec.host, host);
|
|
190
|
+
|
|
191
|
+
host.harnesses += 1;
|
|
192
|
+
|
|
193
|
+
const reporter = spec.onRelay === undefined ? null : this.openReporter(host, spec);
|
|
194
|
+
|
|
195
|
+
return new ImpHarness(
|
|
196
|
+
this.port,
|
|
197
|
+
{
|
|
198
|
+
kind: 'start',
|
|
199
|
+
name: host.name,
|
|
200
|
+
session: buildImpSessionName(spec.session),
|
|
201
|
+
argv: [spec.bin, ...spec.args],
|
|
202
|
+
env: {
|
|
203
|
+
...buildGuestEnv(spec.env),
|
|
204
|
+
...(reporter === null
|
|
205
|
+
? {}
|
|
206
|
+
: { ATC_SOCKET: reporter.path, ATC_BRIDGE: '1', ATC_OUTBOX: reporter.outbox }),
|
|
207
|
+
},
|
|
208
|
+
cwd: spec.cwd,
|
|
209
|
+
cols: spec.cols,
|
|
210
|
+
rows: spec.rows,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
offsets: this.offsets,
|
|
214
|
+
reconnectDelaysMs: this.reconnectDelaysMs,
|
|
215
|
+
...(reporter === null ? {} : { ready: reporter.forward.listening }),
|
|
216
|
+
isSuspending: () => host.suspending,
|
|
217
|
+
onDone: () => {
|
|
218
|
+
reporter?.forward.stop();
|
|
219
|
+
host.harnesses -= 1;
|
|
220
|
+
|
|
221
|
+
if (host.harnesses === 0 && !host.suspending) {
|
|
222
|
+
void this.tryReleaseLease(host);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
|
|
230
|
+
readonly transferArchive = async (archive: Uint8Array, dir: string, host?: string) => {
|
|
231
|
+
const result = await this.runOnHost(host, {
|
|
232
|
+
argv: ['sh', '-c', 'mkdir -p "$1" && tar -x -f - -C "$1"', 'sh', dir],
|
|
233
|
+
stdin: archive,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (result.code !== 0) {
|
|
237
|
+
throw new Error(`tar exited ${result.code ?? 'by a signal'} unpacking into ${dir}`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
readonly runCommand = async (spec: CommandSpec): Promise<CommandResult> => {
|
|
242
|
+
const result = await this.runOnHost(spec.host, { argv: spec.argv, cwd: spec.cwd });
|
|
243
|
+
|
|
244
|
+
const decoder = new TextDecoder();
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
exitCode: result.code ?? 1,
|
|
248
|
+
stdout: decoder.decode(result.stdout),
|
|
249
|
+
stderr: decoder.decode(result.stderr),
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Gives back the daemon's own lease first, since a lease blocks a sleep,
|
|
254
|
+
// then asks impd to sleep the imp without force. Another owner's lease
|
|
255
|
+
// refuses the sleep: the daemon takes its lease back and the refusal
|
|
256
|
+
// carries what impd showed of the other leases.
|
|
257
|
+
readonly suspendHost = async (hostKey: string): Promise<void> => {
|
|
258
|
+
const host = this.hosts.get(hostKey) ?? {
|
|
259
|
+
name: buildImpName(hostKey),
|
|
260
|
+
harnesses: 0,
|
|
261
|
+
renewTimer: null,
|
|
262
|
+
suspending: false,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this.hosts.set(hostKey, host);
|
|
266
|
+
this.stopRenewal(host);
|
|
267
|
+
|
|
268
|
+
host.suspending = true;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
if (this.label !== null) {
|
|
272
|
+
await this.port.releaseLease(host.name, this.label);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await this.port.suspendImp(host.name);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
host.suspending = false;
|
|
278
|
+
|
|
279
|
+
if (error instanceof ImpPortError && error.code === 'LEASED') {
|
|
280
|
+
await this.restoreLease(host);
|
|
281
|
+
|
|
282
|
+
throw buildLeasedRefusal(host.name, error.data);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw toHostRefusal(error, host.name);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// A destroy ends every lease on the imp with it.
|
|
290
|
+
readonly destroyHost = async (hostKey: string): Promise<void> => {
|
|
291
|
+
const host = this.hosts.get(hostKey);
|
|
292
|
+
const name = buildImpName(hostKey);
|
|
293
|
+
|
|
294
|
+
if (host !== undefined) {
|
|
295
|
+
this.stopRenewal(host);
|
|
296
|
+
|
|
297
|
+
host.suspending = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await this.port.destroyImp(name);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (!(error instanceof ImpPortError && error.code === 'NOT_FOUND')) {
|
|
304
|
+
if (host !== undefined) {
|
|
305
|
+
host.suspending = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw toHostRefusal(error, name);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.hosts.delete(hostKey);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
readonly dispose = (): void => {
|
|
316
|
+
for (const host of this.hosts.values()) {
|
|
317
|
+
this.stopRenewal(host);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Takes back what a failed prepare left: an imp it created is destroyed,
|
|
322
|
+
// which ends the lease on it too, since no session was ever listed on it;
|
|
323
|
+
// on an imp that existed before, only the lease this prepare took is
|
|
324
|
+
// given back, and the imp itself stays.
|
|
325
|
+
private async tryUndoPrepare(
|
|
326
|
+
name: string,
|
|
327
|
+
label: string,
|
|
328
|
+
created: boolean,
|
|
329
|
+
leased: boolean,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
try {
|
|
332
|
+
if (created) {
|
|
333
|
+
await this.port.destroyImp(name);
|
|
334
|
+
} else if (leased) {
|
|
335
|
+
await this.port.releaseLease(name, label);
|
|
336
|
+
}
|
|
337
|
+
} catch {}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Readies the folder the harnesses' report sockets live in, and copies
|
|
341
|
+
// the provider's atc binary in when a harness needs atc and the imp has
|
|
342
|
+
// none, as a cold boot of an imp leaves it.
|
|
343
|
+
private async setupGuest(name: string, installATC: boolean): Promise<void> {
|
|
344
|
+
const ready = await this.port.runCommand(name, {
|
|
345
|
+
argv: [
|
|
346
|
+
'sh',
|
|
347
|
+
'-c',
|
|
348
|
+
'mkdir -p "$1/run" && { [ -z "$2" ] || [ -x "$2" ]; }',
|
|
349
|
+
'sh',
|
|
350
|
+
this.guest.dir,
|
|
351
|
+
this.guest.atc ?? '',
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (ready.code === 0 || !installATC) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (this.atcBinary === null) {
|
|
360
|
+
throw new DaemonError(
|
|
361
|
+
'unsupported_operation',
|
|
362
|
+
`imp ${name} has no executable atc at ${this.guest.atc ?? '(none)'}`,
|
|
363
|
+
{ provider: 'imp', problem: 'no_guest_atc' },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const binary = await Bun.file(this.atcBinary).bytes();
|
|
368
|
+
|
|
369
|
+
const unpacked = await this.port.runCommand(name, {
|
|
370
|
+
argv: ['sh', '-c', 'mkdir -p "$1" && tar -x -f - -C "$1"', 'sh', this.guest.dir],
|
|
371
|
+
stdin: buildTarArchive([{ path: 'bin/atc', content: binary, mode: 0o755 }]),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (unpacked.code !== 0) {
|
|
375
|
+
throw new Error(`tar exited ${unpacked.code ?? 'by a signal'} installing atc in ${name}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A socket inside the imp that serves one harness: each connection a
|
|
380
|
+
// process opens there reaches the daemon as that harness's relay, and
|
|
381
|
+
// nothing else reaches the daemon through it.
|
|
382
|
+
private openReporter(host: ImpHost, spec: HarnessSpec) {
|
|
383
|
+
const base = `${this.guest.dir}/run/${buildSocketName(spec.session)}`;
|
|
384
|
+
const path = `${base}.sock`;
|
|
385
|
+
const onRelay = spec.onRelay;
|
|
386
|
+
|
|
387
|
+
const forward = this.port.openReverseForward(host.name, path, (connection) => {
|
|
388
|
+
onRelay?.(toHarnessRelay(connection));
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Reports the guest has not seen answered wait in the outbox beside
|
|
392
|
+
// the socket, for the next connection to send again.
|
|
393
|
+
return { path, outbox: `${base}.outbox`, forward };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async runOnHost(
|
|
397
|
+
hostKey: string | undefined,
|
|
398
|
+
|
|
399
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
|
|
400
|
+
command: Readonly<{ argv: readonly string[]; cwd?: string; stdin?: Uint8Array }>,
|
|
401
|
+
) {
|
|
402
|
+
if (hostKey === undefined) {
|
|
403
|
+
throw new Error('the imp provider runs a command only on a session host');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
return await this.port.runCommand(buildImpName(hostKey), command);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw toHostRefusal(error, buildImpName(hostKey));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A renewal that finds the lease gone, as a forced sleep leaves it, stops
|
|
414
|
+
// renewing: the daemon never takes a lease back behind an owner who ended
|
|
415
|
+
// it. A host with no harness left gives its lease back.
|
|
416
|
+
private startRenewal(host: ImpHost, label: string): void {
|
|
417
|
+
if (host.renewTimer !== null) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
host.renewTimer = setInterval(
|
|
422
|
+
() => {
|
|
423
|
+
if (host.harnesses === 0) {
|
|
424
|
+
void this.tryReleaseLease(host);
|
|
425
|
+
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
void this.tryRenewLease(host, label);
|
|
430
|
+
},
|
|
431
|
+
(this.leaseSeconds * 1000) / 3,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
host.renewTimer.unref();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private async tryRenewLease(host: ImpHost, label: string): Promise<boolean> {
|
|
438
|
+
try {
|
|
439
|
+
await this.port.renewLease(host.name, label, this.leaseSeconds);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof ImpPortError && error.code === 'LEASE_NOT_HELD') {
|
|
442
|
+
this.stopRenewal(host);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private stopRenewal(host: ImpHost): void {
|
|
452
|
+
if (host.renewTimer !== null) {
|
|
453
|
+
clearInterval(host.renewTimer);
|
|
454
|
+
|
|
455
|
+
host.renewTimer = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async restoreLease(host: ImpHost): Promise<void> {
|
|
460
|
+
if (this.label === null) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await this.port.acquireLease(host.name, this.label, this.leaseSeconds);
|
|
466
|
+
} catch {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
this.startRenewal(host, this.label);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private async tryReleaseLease(host: ImpHost): Promise<boolean> {
|
|
474
|
+
this.stopRenewal(host);
|
|
475
|
+
|
|
476
|
+
if (this.label === null) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
return await this.port.releaseLease(host.name, this.label);
|
|
482
|
+
} catch {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// What the provider follows of one imp: its name, how many harnesses run
|
|
489
|
+
// there, the renewal holding the daemon's lease, and whether a sleep or a
|
|
490
|
+
// destroy the provider asked for is ending its connections.
|
|
491
|
+
interface ImpHost {
|
|
492
|
+
readonly name: string;
|
|
493
|
+
harnesses: number;
|
|
494
|
+
renewTimer: ReturnType<typeof setInterval> | null;
|
|
495
|
+
suspending: boolean;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The imp a host key runs on: `atc-` and the first 20 letters and digits of
|
|
500
|
+
* the key, which is the atc session id of the session that owns the host.
|
|
501
|
+
*/
|
|
502
|
+
function buildImpName(hostKey: string): string {
|
|
503
|
+
return `atc-${hostKey
|
|
504
|
+
.toLowerCase()
|
|
505
|
+
.replaceAll(/[^a-z0-9]/g, '')
|
|
506
|
+
.slice(0, 20)}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function buildImpSessionName(sessionID: string): string {
|
|
510
|
+
return `atc-${sessionID
|
|
511
|
+
.toLowerCase()
|
|
512
|
+
.replaceAll(/[^a-z0-9]/g, '')
|
|
513
|
+
.slice(0, 32)}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Short enough that the socket's path stays inside a unix socket's limit.
|
|
517
|
+
function buildSocketName(sessionID: string): string {
|
|
518
|
+
return sessionID
|
|
519
|
+
.toLowerCase()
|
|
520
|
+
.replaceAll(/[^a-z0-9]/g, '')
|
|
521
|
+
.slice(0, 16);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Frames a guest connection's bytes into lines, both ways.
|
|
525
|
+
function toHarnessRelay(connection: ImpRelayConnection): HarnessRelay {
|
|
526
|
+
const decoder = new TextDecoder();
|
|
527
|
+
const encoder = new TextEncoder();
|
|
528
|
+
|
|
529
|
+
const lineListeners: ((line: string) => void)[] = [];
|
|
530
|
+
let pending = '';
|
|
531
|
+
|
|
532
|
+
connection.onData((data) => {
|
|
533
|
+
const lines = `${pending}${decoder.decode(data, { stream: true })}`.split('\n');
|
|
534
|
+
|
|
535
|
+
pending = lines.pop() ?? '';
|
|
536
|
+
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
if (line.trim() !== '') {
|
|
539
|
+
for (const listener of lineListeners) {
|
|
540
|
+
listener(line);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
onLine: (listener) => {
|
|
548
|
+
lineListeners.push(listener);
|
|
549
|
+
},
|
|
550
|
+
onClose: (listener) => {
|
|
551
|
+
connection.onClose(listener);
|
|
552
|
+
},
|
|
553
|
+
writeLine: (line) => connection.write(encoder.encode(`${line}\n`)),
|
|
554
|
+
close: () => {
|
|
555
|
+
connection.close();
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// The binary the provider copies into an imp: the running daemon itself
|
|
561
|
+
// when it is a compiled Linux binary, since an imp runs Linux.
|
|
562
|
+
function findOwnLinuxBinary(): string | null {
|
|
563
|
+
return isCompiledBinary() && process.platform === 'linux' ? process.execPath : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// A guest harness gets the variables atc sets for it and the guest's own
|
|
567
|
+
// terminal, locale, and PATH, never the daemon's environment. The daemon's
|
|
568
|
+
// reporter socket path means nothing inside the imp.
|
|
569
|
+
function buildGuestEnv(env: Readonly<Record<string, string>>): Record<string, string> {
|
|
570
|
+
const own = Object.fromEntries(Object.entries(env).filter(([key]) => key !== 'ATC_SOCKET'));
|
|
571
|
+
|
|
572
|
+
return { TERM: 'xterm-256color', LANG: 'C.UTF-8', PATH: GUEST_PATH, ...own };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function buildLeasedRefusal(name: string, data: unknown): DaemonError {
|
|
576
|
+
const record = typeof data === 'object' && data !== null ? data : {};
|
|
577
|
+
const leases = 'leases' in record && Array.isArray(record.leases) ? record.leases : [];
|
|
578
|
+
|
|
579
|
+
const otherCount =
|
|
580
|
+
'otherCount' in record && typeof record.otherCount === 'number' ? record.otherCount : 0;
|
|
581
|
+
|
|
582
|
+
return new DaemonError(
|
|
583
|
+
'host_leased',
|
|
584
|
+
`imp ${name} stays awake: another owner holds a lease on it, and atc never forces a sleep`,
|
|
585
|
+
{ leases, otherCount },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function toHostRefusal(error: unknown, name: string): DaemonError {
|
|
590
|
+
if (error instanceof DaemonError) {
|
|
591
|
+
return error;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const code = error instanceof ImpPortError ? error.code : 'UNKNOWN';
|
|
595
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
596
|
+
|
|
597
|
+
return new DaemonError('host_unavailable', `imp ${name} cannot be used: ${detail}`, {
|
|
598
|
+
provider: 'imp',
|
|
599
|
+
problem: code.toLowerCase(),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What one session bridge was opened for: the session, the target and
|
|
5
|
+
* target identity it ran on, the host it runs on, and the epoch of the
|
|
6
|
+
* harness start or attach that opened it. Every start or attach of the
|
|
7
|
+
* session takes a new epoch.
|
|
8
|
+
*/
|
|
9
|
+
export interface BridgeBinding {
|
|
10
|
+
readonly sessionID: SessionID;
|
|
11
|
+
readonly target: string;
|
|
12
|
+
readonly targetIdentity: string;
|
|
13
|
+
readonly hostKey: SessionID;
|
|
14
|
+
readonly epoch: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The fields of a live session a binding is checked against.
|
|
18
|
+
interface BoundSession {
|
|
19
|
+
readonly id: SessionID;
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly targetIdentity: string;
|
|
22
|
+
readonly hostKey: SessionID;
|
|
23
|
+
readonly bridgeEpoch: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a bridge's binding still matches the live session it serves. A
|
|
28
|
+
* session that is gone, moved to another target or identity or host, or
|
|
29
|
+
* started or attached again since the bridge opened fails the check.
|
|
30
|
+
*/
|
|
31
|
+
export function isBindingCurrent(
|
|
32
|
+
binding: Readonly<BridgeBinding>,
|
|
33
|
+
session: Readonly<BoundSession> | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
return (
|
|
36
|
+
session !== undefined &&
|
|
37
|
+
session.id === binding.sessionID &&
|
|
38
|
+
session.target === binding.target &&
|
|
39
|
+
session.targetIdentity === binding.targetIdentity &&
|
|
40
|
+
session.hostKey === binding.hostKey &&
|
|
41
|
+
session.bridgeEpoch === binding.epoch
|
|
42
|
+
);
|
|
43
|
+
}
|