@zgeoff/atc 3.5.0 → 3.6.1
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 +5 -2
- package/src/agents/gateway-adapter.ts +38 -20
- package/src/build-migrated-config.ts +1 -0
- package/src/daemon/daemon.ts +9 -0
- package/src/daemon/sessions.ts +9 -4
- package/src/shared/check-gateway-auth.ts +16 -10
- package/src/shared/collect-agents.ts +0 -1
- package/src/shared/collect-gateways.ts +4 -2
- package/src/test-utils/build-stub-failing-attachment-listener.ts +16 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +9 -8
- package/src/test-utils/create-stub-harness-guest.ts +5 -3
- package/src/test-utils/create-stub-imp-port.ts +87 -87
- package/src/test-utils/run-command.ts +73 -0
- package/src/test-utils/start-tui-harness.ts +23 -6
- package/src/test-utils/try-create-listeners.ts +20 -0
- package/src/test-utils/try-bind-addresses.ts +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
4
4
|
"description": "Terminal control tower for coding-agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
@@ -85,5 +85,8 @@
|
|
|
85
85
|
"smol-toml": "1.9.0",
|
|
86
86
|
"tinypool": "2.1.2"
|
|
87
87
|
},
|
|
88
|
-
"packageManager": "bun@1.3.10"
|
|
88
|
+
"packageManager": "bun@1.3.10",
|
|
89
|
+
"patchedDependencies": {
|
|
90
|
+
"knip@6.24.0": "patches/knip@6.24.0.patch"
|
|
91
|
+
}
|
|
89
92
|
}
|
|
@@ -58,7 +58,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
58
58
|
|
|
59
59
|
// A headless turn carries the same settings file the terminal spawn does,
|
|
60
60
|
// so it reaches this backend rather than the default one. A gateway whose
|
|
61
|
-
// credential comes through impd's broker runs no headless turn.
|
|
61
|
+
// credential comes through impd's broker alone runs no headless turn.
|
|
62
62
|
readonly headlessRunner: HeadlessRunner | null;
|
|
63
63
|
|
|
64
64
|
// The CLI's hooks are authoritative; no screen heuristics needed.
|
|
@@ -132,7 +132,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
132
132
|
);
|
|
133
133
|
|
|
134
134
|
this.headlessRunner =
|
|
135
|
-
headlessRun === null || gateway
|
|
135
|
+
headlessRun === null || isBrokerOnlyGateway(gateway)
|
|
136
136
|
? null
|
|
137
137
|
: makeClaudeHeadlessRunner(headlessRun, {
|
|
138
138
|
claudeBin: gateway.bin,
|
|
@@ -169,15 +169,16 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
169
169
|
return {
|
|
170
170
|
gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
|
|
171
171
|
profiles: this.config.authProfiles,
|
|
172
|
-
brokerRequired:
|
|
172
|
+
brokerRequired: isBrokerOnlyGateway(this.gateway),
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// A gateway whose credential comes through impd's broker never
|
|
177
|
-
// the daemon's machine: started without the broker, the CLI
|
|
178
|
-
// whatever credential it holds to the gateway's host.
|
|
176
|
+
// A gateway whose credential comes through impd's broker alone never
|
|
177
|
+
// starts on the daemon's machine: started without the broker, the CLI
|
|
178
|
+
// would send whatever credential it holds to the gateway's host. One
|
|
179
|
+
// with a credential helper as well starts there under that helper.
|
|
179
180
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
180
|
-
if (this.gateway
|
|
181
|
+
if (isBrokerOnlyGateway(this.gateway)) {
|
|
181
182
|
throw this.buildBrokerRefusal('which only an imp target can give it');
|
|
182
183
|
}
|
|
183
184
|
|
|
@@ -186,7 +187,13 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
186
187
|
|
|
187
188
|
return {
|
|
188
189
|
bin: this.gateway.bin,
|
|
189
|
-
args: this.buildArgs(
|
|
190
|
+
args: this.buildArgs(
|
|
191
|
+
this.gateway.args,
|
|
192
|
+
opts,
|
|
193
|
+
modeArgs,
|
|
194
|
+
this.writeSettings(),
|
|
195
|
+
this.writeBridge(),
|
|
196
|
+
),
|
|
190
197
|
};
|
|
191
198
|
}
|
|
192
199
|
|
|
@@ -196,8 +203,9 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
196
203
|
// and placeholders in place of the credential, which the broker swaps
|
|
197
204
|
// for the real one on the host's side. A shell seeds the config folder
|
|
198
205
|
// before it runs the CLI, since a transferred file would replace the
|
|
199
|
-
// state an earlier run left.
|
|
200
|
-
//
|
|
206
|
+
// state an earlier run left. The launch takes the auth's own arguments
|
|
207
|
+
// when it sets them, and never the credential helper, which runs on the
|
|
208
|
+
// daemon's machine alone. A gateway without auth never runs remotely.
|
|
201
209
|
planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
|
|
202
210
|
if (this.gateway.auth === undefined) {
|
|
203
211
|
return null;
|
|
@@ -244,11 +252,14 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
244
252
|
argv,
|
|
245
253
|
);
|
|
246
254
|
|
|
255
|
+
const args = this.gateway.auth.args ?? this.gateway.args;
|
|
256
|
+
|
|
247
257
|
const launch = buildClaudeGuestLaunch(guest.dir, [
|
|
248
258
|
this.gateway.bin,
|
|
249
259
|
...this.buildArgs(
|
|
260
|
+
args,
|
|
250
261
|
opts,
|
|
251
|
-
this.buildGuestModeArgs(),
|
|
262
|
+
this.buildGuestModeArgs(args),
|
|
252
263
|
`${guest.dir}/${settingsPath}`,
|
|
253
264
|
`${guest.dir}/atc-bridge`,
|
|
254
265
|
),
|
|
@@ -291,10 +302,10 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
291
302
|
// explicit flag, so that mode overrides the one the CLI would restore, and
|
|
292
303
|
// the generated settings file, because
|
|
293
304
|
// without it the CLI would resume the session against the default backend.
|
|
294
|
-
// A gateway whose credential comes through impd's broker has none,
|
|
295
|
-
// outside atc the broker never reaches it.
|
|
305
|
+
// A gateway whose credential comes through impd's broker alone has none,
|
|
306
|
+
// since outside atc the broker never reaches it.
|
|
296
307
|
buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
|
|
297
|
-
if (this.gateway
|
|
308
|
+
if (isBrokerOnlyGateway(this.gateway)) {
|
|
298
309
|
return null;
|
|
299
310
|
}
|
|
300
311
|
|
|
@@ -312,13 +323,14 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
private buildArgs(
|
|
326
|
+
args: readonly string[],
|
|
315
327
|
opts: SpawnOptions,
|
|
316
328
|
modeArgs: readonly string[],
|
|
317
329
|
settings: string,
|
|
318
330
|
pluginDir: string,
|
|
319
331
|
): string[] {
|
|
320
332
|
return [
|
|
321
|
-
...buildClaudeOverrideArgs(
|
|
333
|
+
...buildClaudeOverrideArgs(args, opts),
|
|
322
334
|
...modeArgs,
|
|
323
335
|
'--settings',
|
|
324
336
|
settings,
|
|
@@ -332,11 +344,11 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
332
344
|
|
|
333
345
|
// A brokered session's Claude config is fresh, so the CLI's own default
|
|
334
346
|
// mode would apply rather than the one the owner's settings set. Every
|
|
335
|
-
// start therefore names its mode: the one the
|
|
336
|
-
// settings set, else the CLI's manual mode, which asks before
|
|
337
|
-
// action.
|
|
338
|
-
private buildGuestModeArgs(): string[] {
|
|
339
|
-
if (findFlagValue(
|
|
347
|
+
// start therefore names its mode: the one the launch's arguments or the
|
|
348
|
+
// gateway's settings set, else the CLI's manual mode, which asks before
|
|
349
|
+
// each action.
|
|
350
|
+
private buildGuestModeArgs(args: readonly string[]): string[] {
|
|
351
|
+
if (findFlagValue(args, ['--permission-mode']) !== null) {
|
|
340
352
|
return [];
|
|
341
353
|
}
|
|
342
354
|
|
|
@@ -464,6 +476,12 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
464
476
|
}
|
|
465
477
|
}
|
|
466
478
|
|
|
479
|
+
// Whether a gateway takes its credential from impd's broker with no
|
|
480
|
+
// credential helper to fall back on, so it starts only behind the broker.
|
|
481
|
+
function isBrokerOnlyGateway(gateway: GatewayConfig): boolean {
|
|
482
|
+
return gateway.auth !== undefined && gateway.apiKeyHelper === undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
467
485
|
// The variable the Claude CLI sends as a bearer authorization header, and
|
|
468
486
|
// the value impd's broker replaces with the credential on the host's side.
|
|
469
487
|
const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
|
|
@@ -142,6 +142,7 @@ function renderAgentEntry(entry: AgentEntry): Record<string, unknown> {
|
|
|
142
142
|
...(Object.keys(entry.auth.placeholderEnv).length > 0
|
|
143
143
|
? { placeholderEnv: entry.auth.placeholderEnv }
|
|
144
144
|
: {}),
|
|
145
|
+
...(entry.auth.args === undefined ? {} : { args: entry.auth.args }),
|
|
145
146
|
...(entry.mcpServers === undefined
|
|
146
147
|
? {}
|
|
147
148
|
: {
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -163,6 +163,11 @@ export interface DaemonOptions {
|
|
|
163
163
|
// Starts the eject settle timer; defaults to a real `setTimeout`.
|
|
164
164
|
readonly scheduleEjectSettle?: SettleScheduler;
|
|
165
165
|
|
|
166
|
+
// How long a failed spawn's rollback waits for the killed process to
|
|
167
|
+
// exit, once after its kill and once more after a forced kill; 2 s when
|
|
168
|
+
// unset.
|
|
169
|
+
readonly failedSpawnExitWaitMs?: number;
|
|
170
|
+
|
|
166
171
|
// A fleet-wide restore revives one session at a time, waiting for each to
|
|
167
172
|
// report it has booted before starting the next so the machine is not
|
|
168
173
|
// buried under a dozen simultaneous agent boots. This caps how long a
|
|
@@ -427,6 +432,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
427
432
|
mgr.log = opts.log;
|
|
428
433
|
}
|
|
429
434
|
|
|
435
|
+
if (opts.failedSpawnExitWaitMs !== undefined) {
|
|
436
|
+
mgr.failedSpawnExitWaitMs = opts.failedSpawnExitWaitMs;
|
|
437
|
+
}
|
|
438
|
+
|
|
430
439
|
const records = new PublishedRecords({
|
|
431
440
|
store,
|
|
432
441
|
daemonID: store.daemonID,
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -290,8 +290,8 @@ interface SpawnReadied {
|
|
|
290
290
|
// stored identity ran on.
|
|
291
291
|
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
292
292
|
|
|
293
|
-
// How long a failed spawn's rollback waits for the killed process to exit
|
|
294
|
-
//
|
|
293
|
+
// How long a failed spawn's rollback waits for the killed process to exit
|
|
294
|
+
// unless the manager is given another wait.
|
|
295
295
|
const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
|
|
296
296
|
|
|
297
297
|
// How a harness takes its credential from impd's broker: the broker the
|
|
@@ -364,6 +364,11 @@ export class SessionManager {
|
|
|
364
364
|
// sessions start without a record.
|
|
365
365
|
records: PublishedRecords | null = null;
|
|
366
366
|
|
|
367
|
+
// How long a failed spawn's rollback waits for the killed process to
|
|
368
|
+
// exit, in milliseconds, once after its kill and once more after a forced
|
|
369
|
+
// kill.
|
|
370
|
+
failedSpawnExitWaitMs = FAILED_SPAWN_EXIT_WAIT_MS;
|
|
371
|
+
|
|
367
372
|
// Whether any registered adapter has a screen detector, decided once at
|
|
368
373
|
// construction since the registry never changes afterward. Lets a hot path
|
|
369
374
|
// that only cares about this answer skip walking live sessions to find it.
|
|
@@ -2531,7 +2536,7 @@ export class SessionManager {
|
|
|
2531
2536
|
// Waits for a killed harness to exit, ending it with a signal it cannot
|
|
2532
2537
|
// ignore when the first wait runs out and its provider can send one.
|
|
2533
2538
|
private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
|
|
2534
|
-
const exited = await pty.waitForExit(
|
|
2539
|
+
const exited = await pty.waitForExit(this.failedSpawnExitWaitMs);
|
|
2535
2540
|
|
|
2536
2541
|
if (exited) {
|
|
2537
2542
|
return true;
|
|
@@ -2543,7 +2548,7 @@ export class SessionManager {
|
|
|
2543
2548
|
|
|
2544
2549
|
pty.killForced();
|
|
2545
2550
|
|
|
2546
|
-
return pty.waitForExit(
|
|
2551
|
+
return pty.waitForExit(this.failedSpawnExitWaitMs);
|
|
2547
2552
|
}
|
|
2548
2553
|
|
|
2549
2554
|
// Whether a failed spawn's rollback leaves the session free to revive:
|
|
@@ -7,11 +7,14 @@ import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
|
7
7
|
/**
|
|
8
8
|
* The auth profiles a gateway's sessions are bound to through impd's
|
|
9
9
|
* broker, and the variables each session gets in place of a credential,
|
|
10
|
-
* every value the fixed placeholder.
|
|
10
|
+
* every value the fixed placeholder. `args`, when set, replaces the
|
|
11
|
+
* gateway's arguments on a launch behind the broker, whose host lays out
|
|
12
|
+
* its files apart from the daemon's.
|
|
11
13
|
*/
|
|
12
14
|
export interface GatewayAuth {
|
|
13
15
|
readonly profiles: readonly string[];
|
|
14
16
|
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
17
|
+
readonly args?: readonly string[];
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/**
|
|
@@ -19,7 +22,6 @@ export interface GatewayAuth {
|
|
|
19
22
|
*/
|
|
20
23
|
interface GatewayAuthEntry {
|
|
21
24
|
readonly baseURL?: string | undefined;
|
|
22
|
-
readonly apiKeyHelper?: string | undefined;
|
|
23
25
|
readonly env: Readonly<Record<string, string>>;
|
|
24
26
|
readonly settings?: Readonly<Record<string, unknown>> | undefined;
|
|
25
27
|
}
|
|
@@ -40,15 +42,9 @@ export function checkGatewayAuth(
|
|
|
40
42
|
|
|
41
43
|
const problems: string[] = [];
|
|
42
44
|
|
|
43
|
-
if (entry.apiKeyHelper !== undefined) {
|
|
44
|
-
problems.push(
|
|
45
|
-
'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
45
|
if (entry.settings?.['apiKeyHelper'] !== undefined) {
|
|
50
46
|
problems.push(
|
|
51
|
-
'settings.apiKeyHelper cannot be set together with auth
|
|
47
|
+
'settings.apiKeyHelper cannot be set together with auth; apiKeyHelper holds the credential helper of a launch without the broker',
|
|
52
48
|
);
|
|
53
49
|
}
|
|
54
50
|
|
|
@@ -128,7 +124,17 @@ function parseGatewayAuth(raw: unknown): GatewayAuth | string {
|
|
|
128
124
|
env[key] = value;
|
|
129
125
|
}
|
|
130
126
|
|
|
131
|
-
|
|
127
|
+
const args = raw['args'];
|
|
128
|
+
|
|
129
|
+
if (args === undefined) {
|
|
130
|
+
return { profiles: profiles.map(String), placeholderEnv: env };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) {
|
|
134
|
+
return 'auth.args must be an array of strings';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { profiles: profiles.map(String), placeholderEnv: env, args: args.map(String) };
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
// A settings env's variable names; anything but an object sets none.
|
|
@@ -10,8 +10,10 @@ import { isRecord } from './report';
|
|
|
10
10
|
* A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
|
|
11
11
|
* its own row in the spawn menu, and its own generated settings file. The
|
|
12
12
|
* credential is never held here: a helper command supplies it at run time,
|
|
13
|
-
*
|
|
14
|
-
* host's side, so it stays out of the file atc writes.
|
|
13
|
+
* and, with `auth`, impd's credential broker adds it to each request on the
|
|
14
|
+
* host's side, so it stays out of the file atc writes. A gateway with both
|
|
15
|
+
* runs the helper on the daemon's machine and takes the broker's
|
|
16
|
+
* credential on an imp.
|
|
15
17
|
*/
|
|
16
18
|
export interface GatewayConfig {
|
|
17
19
|
readonly id: AgentID;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HarnessAttachment } from '../daemon/execution-provider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An attachment listener that fails as a listener whose write to a closed
|
|
5
|
+
* peer fails: it throws an error with the given message each time a
|
|
6
|
+
* connection is attached, and returns quietly while one is reattaching.
|
|
7
|
+
*/
|
|
8
|
+
export function buildStubFailingAttachmentListener(
|
|
9
|
+
message: string,
|
|
10
|
+
): (attachment: HarnessAttachment) => void {
|
|
11
|
+
return (attachment) => {
|
|
12
|
+
if (attachment === 'attached') {
|
|
13
|
+
throw new Error(message);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
2
2
|
import { buildMockAgentAdapter } from './build-mock-agent-adapter';
|
|
3
|
+
import { runCommand } from './run-command';
|
|
3
4
|
|
|
4
5
|
interface Plan {
|
|
5
6
|
readonly bin: string;
|
|
@@ -34,20 +35,20 @@ interface FailingAgentAdapterConfig {
|
|
|
34
35
|
* read finds no headless runner. Every member but the spawn plan and the
|
|
35
36
|
* headless runner is the mock adapter's.
|
|
36
37
|
*
|
|
37
|
-
* With a ready pipe, the stub makes the pipe at that path
|
|
38
|
-
* failing read blocks until a process writes its
|
|
39
|
-
* headless runner read is synchronous for. A read that finds no writer
|
|
38
|
+
* With a ready pipe, the stub makes the pipe at that path before it
|
|
39
|
+
* resolves, and the first failing read blocks until a process writes its
|
|
40
|
+
* pid there, which the headless runner read is synchronous for. A read that finds no writer
|
|
40
41
|
* within the timeout throws instead of failing the start. `getReadyPID`
|
|
41
42
|
* returns the pid that read took. `countPlans` reads how many spawns the
|
|
42
43
|
* adapter has planned.
|
|
43
44
|
*/
|
|
44
|
-
export function createStubFailingAgentAdapter(config: FailingAgentAdapterConfig) {
|
|
45
|
+
export async function createStubFailingAgentAdapter(config: FailingAgentAdapterConfig) {
|
|
45
46
|
let planned = 0;
|
|
46
47
|
let readsToFail = 0;
|
|
47
48
|
let readyPID: number | null = null;
|
|
48
49
|
|
|
49
50
|
if (config.ready !== null) {
|
|
50
|
-
createPipe(config.ready.path);
|
|
51
|
+
await createPipe(config.ready.path);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const adapter: AgentAdapter = {
|
|
@@ -90,11 +91,11 @@ export function createStubFailingAgentAdapter(config: FailingAgentAdapterConfig)
|
|
|
90
91
|
};
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function createPipe(path: string): void {
|
|
94
|
-
const made =
|
|
94
|
+
async function createPipe(path: string): Promise<void> {
|
|
95
|
+
const made = await runCommand(['mkfifo', path]);
|
|
95
96
|
|
|
96
97
|
if (made.exitCode !== 0) {
|
|
97
|
-
throw new Error(`mkfifo could not make ${path} (${made.stderr.
|
|
98
|
+
throw new Error(`mkfifo could not make ${path} (${made.stderr.trim()})`);
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { createStubBin } from './create-stub-bin';
|
|
3
|
+
import { runCommand } from './run-command';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Creates a stand-in guest program for a harness to run, under the
|
|
@@ -8,12 +9,13 @@ import { createStubBin } from './create-stub-bin';
|
|
|
8
9
|
* `GOT:<line>`, prints `SIZE:<rows> <cols>` on `size`, and exits 3 on
|
|
9
10
|
* `quit`. On `later` it waits for a line on the named pipe at `burstPath`,
|
|
10
11
|
* then prints 300000 `x` bytes, more than impd's ring keeps, a newline, and
|
|
11
|
-
* `BURST_DONE`.
|
|
12
|
+
* `BURST_DONE`. Resolves with the script's path and the pipe's once the pipe
|
|
13
|
+
* exists.
|
|
12
14
|
*/
|
|
13
|
-
export function createStubHarnessGuest(dir: string) {
|
|
15
|
+
export async function createStubHarnessGuest(dir: string) {
|
|
14
16
|
const burstPath = join(dir, 'burst');
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
await runCommand(['mkfifo', burstPath]);
|
|
17
19
|
|
|
18
20
|
const path = createStubBin(
|
|
19
21
|
dir,
|
|
@@ -31,16 +31,92 @@ import { isImpNameAllowed } from '../daemon/is-imp-name-allowed';
|
|
|
31
31
|
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
32
32
|
import { registerTestCleanup } from './register-test-cleanup';
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Creates an in-process stand-in for impd behind the imp port, calling impd
|
|
36
|
+
* as the principal, `token:atc` when absent. Each imp is a set of
|
|
37
|
+
* real `bun-pty` processes on this machine; a sleeping imp stops them with
|
|
38
|
+
* SIGSTOP and a wake continues them, so a memory wake keeps each process
|
|
39
|
+
* and its generation. Every session keeps an exact 262144-byte ring with
|
|
40
|
+
* offsets, a fresh attach skips to the next line and sends a mode prelude,
|
|
41
|
+
* and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
|
|
42
|
+
* `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
|
|
43
|
+
* and `FORBIDDEN`. Leases belong to principals; the port acts as
|
|
44
|
+
* `principal`, and a test adds other owners' leases, cold boots, and
|
|
45
|
+
* dropped sockets through the controls. Grants follow impd 0.27: the
|
|
46
|
+
* caller's identity must reach the imp and, under imp patterns, list the
|
|
47
|
+
* secret as grantable; a grant is idempotent, one secret per host, and a
|
|
48
|
+
* destroyed imp or a rebound or removed secret takes its grants with it. A
|
|
49
|
+
* start or an attach that requires the broker is refused with
|
|
50
|
+
* `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
|
|
51
|
+
* while the broker fails or the imp holds no grant, when the start sets a
|
|
52
|
+
* broker variable, and when it would join a process that started without
|
|
53
|
+
* the broker required. Once the current test finishes, the stand-in kills
|
|
54
|
+
* every process and stops every forward it holds, so it must be created inside
|
|
55
|
+
* a test; `stop` does so sooner, and a second stop does nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function createStubImpPort(principal = 'token:atc'): StubImpPort {
|
|
58
|
+
return new StubImpPort(principal);
|
|
59
|
+
}
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
interface StubLease {
|
|
62
|
+
readonly principal: string;
|
|
63
|
+
readonly label: string;
|
|
64
|
+
readonly until: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface StubConnection {
|
|
68
|
+
readonly handlers: ImpSessionHandlers;
|
|
69
|
+
sent: number;
|
|
70
|
+
finished: boolean;
|
|
71
|
+
process: StubProcess | null;
|
|
72
|
+
readonly stop: (outcome: ImpSessionOutcome) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface StubProcess {
|
|
76
|
+
readonly pty: IPty;
|
|
77
|
+
readonly generation: string;
|
|
78
|
+
ring: Uint8Array;
|
|
79
|
+
end: number;
|
|
80
|
+
exited: { readonly code: number | null } | null;
|
|
81
|
+
|
|
82
|
+
// Ended by a cold boot: its exit is never delivered.
|
|
83
|
+
ended: boolean;
|
|
84
|
+
connection: StubConnection | null;
|
|
85
|
+
|
|
86
|
+
// Whether its start required the broker, which an attach that requires
|
|
87
|
+
// it needs.
|
|
88
|
+
readonly requireBroker: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface StubImp {
|
|
92
|
+
readonly id: string;
|
|
93
|
+
readonly name: string;
|
|
94
|
+
state: ImpState;
|
|
95
|
+
bootId: string;
|
|
96
|
+
coldBoots: ColdBoot[];
|
|
97
|
+
readonly leases: Map<string, StubLease>;
|
|
98
|
+
readonly sessions: Map<string, StubProcess>;
|
|
99
|
+
readonly previous: Map<string, PreviousGeneration>;
|
|
100
|
+
|
|
101
|
+
// The secrets granted to the imp.
|
|
102
|
+
readonly grants: Set<string>;
|
|
103
|
+
}
|
|
39
104
|
|
|
40
105
|
// The PATH every stub process runs with: a guest's own, never the
|
|
41
106
|
// daemon's.
|
|
42
107
|
const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
43
108
|
|
|
109
|
+
interface StubRelay {
|
|
110
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
|
|
111
|
+
readonly dataListeners: ((data: Uint8Array) => void)[];
|
|
112
|
+
readonly closeListeners: (() => void)[];
|
|
113
|
+
|
|
114
|
+
// Bytes a write handed over that the socket has not taken yet, and the
|
|
115
|
+
// writes waiting for them to go.
|
|
116
|
+
unsent: Uint8Array;
|
|
117
|
+
readonly roomWaiters: (() => void)[];
|
|
118
|
+
}
|
|
119
|
+
|
|
44
120
|
// A hold on the commands whose argv holds its text: entered resolves with
|
|
45
121
|
// the argv of the first command it holds, and stop lets every command it
|
|
46
122
|
// holds run.
|
|
@@ -49,8 +125,14 @@ interface StubCommandHold {
|
|
|
49
125
|
readonly stop: () => void;
|
|
50
126
|
}
|
|
51
127
|
|
|
128
|
+
// impd keeps exactly this many bytes of each generation's output.
|
|
129
|
+
const RING_BYTES = 262_144;
|
|
130
|
+
|
|
131
|
+
// The mode bytes a fresh attach sends ahead of a ring that has wrapped.
|
|
132
|
+
const PRELUDE = '\u001B[0m';
|
|
133
|
+
|
|
52
134
|
/**
|
|
53
|
-
* The impd stand-in the factory
|
|
135
|
+
* The impd stand-in the factory above creates, one per call.
|
|
54
136
|
*/
|
|
55
137
|
class StubImpPort implements ImpPort {
|
|
56
138
|
// Every port call, in order, as `<call> <imp> [<detail>]`.
|
|
@@ -1635,88 +1717,6 @@ class StubImpPort implements ImpPort {
|
|
|
1635
1717
|
}
|
|
1636
1718
|
}
|
|
1637
1719
|
|
|
1638
|
-
/**
|
|
1639
|
-
* Creates an in-process stand-in for impd behind the imp port, calling impd
|
|
1640
|
-
* as the principal, `token:atc` when absent. Each imp is a set of
|
|
1641
|
-
* real `bun-pty` processes on this machine; a sleeping imp stops them with
|
|
1642
|
-
* SIGSTOP and a wake continues them, so a memory wake keeps each process
|
|
1643
|
-
* and its generation. Every session keeps an exact 262144-byte ring with
|
|
1644
|
-
* offsets, a fresh attach skips to the next line and sends a mode prelude,
|
|
1645
|
-
* and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
|
|
1646
|
-
* `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
|
|
1647
|
-
* and `FORBIDDEN`. Leases belong to principals; the port acts as
|
|
1648
|
-
* `principal`, and a test adds other owners' leases, cold boots, and
|
|
1649
|
-
* dropped sockets through the controls. Grants follow impd 0.27: the
|
|
1650
|
-
* caller's identity must reach the imp and, under imp patterns, list the
|
|
1651
|
-
* secret as grantable; a grant is idempotent, one secret per host, and a
|
|
1652
|
-
* destroyed imp or a rebound or removed secret takes its grants with it. A
|
|
1653
|
-
* start or an attach that requires the broker is refused with
|
|
1654
|
-
* `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
|
|
1655
|
-
* while the broker fails or the imp holds no grant, when the start sets a
|
|
1656
|
-
* broker variable, and when it would join a process that started without
|
|
1657
|
-
* the broker required. Once the current test finishes, the stand-in kills
|
|
1658
|
-
* every process and stops every forward it holds, so it must be created inside
|
|
1659
|
-
* a test; `stop` does so sooner, and a second stop does nothing.
|
|
1660
|
-
*/
|
|
1661
|
-
export function createStubImpPort(principal = 'token:atc'): StubImpPort {
|
|
1662
|
-
return new StubImpPort(principal);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
interface StubLease {
|
|
1666
|
-
readonly principal: string;
|
|
1667
|
-
readonly label: string;
|
|
1668
|
-
readonly until: number;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
interface StubImp {
|
|
1672
|
-
readonly id: string;
|
|
1673
|
-
readonly name: string;
|
|
1674
|
-
state: ImpState;
|
|
1675
|
-
bootId: string;
|
|
1676
|
-
coldBoots: ColdBoot[];
|
|
1677
|
-
readonly leases: Map<string, StubLease>;
|
|
1678
|
-
readonly sessions: Map<string, StubProcess>;
|
|
1679
|
-
readonly previous: Map<string, PreviousGeneration>;
|
|
1680
|
-
|
|
1681
|
-
// The secrets granted to the imp.
|
|
1682
|
-
readonly grants: Set<string>;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
interface StubProcess {
|
|
1686
|
-
readonly pty: IPty;
|
|
1687
|
-
readonly generation: string;
|
|
1688
|
-
ring: Uint8Array;
|
|
1689
|
-
end: number;
|
|
1690
|
-
exited: { readonly code: number | null } | null;
|
|
1691
|
-
|
|
1692
|
-
// Ended by a cold boot: its exit is never delivered.
|
|
1693
|
-
ended: boolean;
|
|
1694
|
-
connection: StubConnection | null;
|
|
1695
|
-
|
|
1696
|
-
// Whether its start required the broker, which an attach that requires
|
|
1697
|
-
// it needs.
|
|
1698
|
-
readonly requireBroker: boolean;
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
interface StubConnection {
|
|
1702
|
-
readonly handlers: ImpSessionHandlers;
|
|
1703
|
-
sent: number;
|
|
1704
|
-
finished: boolean;
|
|
1705
|
-
process: StubProcess | null;
|
|
1706
|
-
readonly stop: (outcome: ImpSessionOutcome) => void;
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
interface StubRelay {
|
|
1710
|
-
// oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
|
|
1711
|
-
readonly dataListeners: ((data: Uint8Array) => void)[];
|
|
1712
|
-
readonly closeListeners: (() => void)[];
|
|
1713
|
-
|
|
1714
|
-
// Bytes a write handed over that the socket has not taken yet, and the
|
|
1715
|
-
// writes waiting for them to go.
|
|
1716
|
-
unsent: Uint8Array;
|
|
1717
|
-
readonly roomWaiters: (() => void)[];
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
1720
|
function buildNotFound(name: string): ImpPortError {
|
|
1721
1721
|
return new ImpPortError('NOT_FOUND', `no imp ${name}`, { kind: 'imp', name });
|
|
1722
1722
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
2
|
+
|
|
3
|
+
interface CommandOptions {
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
|
|
6
|
+
// The whole environment of the run, as the spawn takes it; left out, the
|
|
7
|
+
// run inherits the test's environment.
|
|
8
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
|
|
10
|
+
readonly stdin?: string | Buffer;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CommandResult {
|
|
14
|
+
// Null when a signal ended the run.
|
|
15
|
+
readonly exitCode: number | null;
|
|
16
|
+
readonly signalCode: string | null;
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly stderr: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs a command as its own process and resolves once it exits and its
|
|
23
|
+
* output closes, with its exit code, the signal that ended it, and
|
|
24
|
+
* everything it printed. The run never blocks the test's event loop, so the
|
|
25
|
+
* test's timeout still applies. The command leads a process group of its
|
|
26
|
+
* own, and a run still incomplete when the test finishes has that whole
|
|
27
|
+
* group killed, so a child the command left holding its output ends too.
|
|
28
|
+
* Call it only inside a test, never from a cleanup.
|
|
29
|
+
*/
|
|
30
|
+
export async function runCommand(
|
|
31
|
+
cmd: readonly string[],
|
|
32
|
+
options: Readonly<CommandOptions> = {},
|
|
33
|
+
): Promise<CommandResult> {
|
|
34
|
+
const proc = Bun.spawn([...cmd], {
|
|
35
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
36
|
+
...(options.env === undefined ? {} : { env: { ...options.env } }),
|
|
37
|
+
stdin: options.stdin === undefined ? 'ignore' : Buffer.from(options.stdin),
|
|
38
|
+
stdout: 'pipe',
|
|
39
|
+
stderr: 'pipe',
|
|
40
|
+
detached: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let complete = false;
|
|
44
|
+
|
|
45
|
+
registerTestCleanup(async () => {
|
|
46
|
+
if (!complete) {
|
|
47
|
+
killProcessGroup(proc.pid);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await proc.exited;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const [stdout, stderr] = await Promise.all([
|
|
54
|
+
new Response(proc.stdout).text(),
|
|
55
|
+
new Response(proc.stderr).text(),
|
|
56
|
+
proc.exited,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
complete = true;
|
|
60
|
+
|
|
61
|
+
return { exitCode: proc.exitCode, signalCode: proc.signalCode, stdout, stderr };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A group already empty has nothing left to kill.
|
|
65
|
+
function killProcessGroup(group: number): void {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(-group, 'SIGKILL');
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!(error instanceof Error && Reflect.get(error, 'code') === 'ESRCH')) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -22,6 +22,14 @@ interface TUIHarnessOptions {
|
|
|
22
22
|
// How long a wait gives the client to draw its first byte; 9 seconds
|
|
23
23
|
// unless set.
|
|
24
24
|
readonly bootMs?: number;
|
|
25
|
+
|
|
26
|
+
// The clock the waits read their deadlines from; the wall clock unless
|
|
27
|
+
// set.
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
|
|
30
|
+
// Waits out the interval between a wait's retries; a real sleep unless
|
|
31
|
+
// set.
|
|
32
|
+
readonly wait?: (ms: number) => Promise<void>;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -39,12 +47,16 @@ interface TUIHarnessOptions {
|
|
|
39
47
|
* transports the fixture repositories need, with the fields given laid over
|
|
40
48
|
* them. `env` is the environment the client runs with, so a daemon started
|
|
41
49
|
* with it serves the client. A wait made before the client draws anything
|
|
42
|
-
* gets `bootMs` for that first byte.
|
|
50
|
+
* gets `bootMs` for that first byte. Every wait reads its deadline from
|
|
51
|
+
* `now` and sleeps between retries through `wait`, the wall clock and a real
|
|
52
|
+
* sleep unless the options set them. `stop` kills every client a boot
|
|
43
53
|
* started, stops the daemon in the home, and removes the home. That stop runs once the current test
|
|
44
54
|
* finishes, so it must run inside a test; calling `stop` sooner runs it
|
|
45
55
|
* then, and a second stop does nothing.
|
|
46
56
|
*/
|
|
47
57
|
export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
58
|
+
const now = options.now ?? Date.now;
|
|
59
|
+
const wait = options.wait ?? Bun.sleep;
|
|
48
60
|
const tmp = setupTempDir('atc-tui-');
|
|
49
61
|
|
|
50
62
|
// Registered after the home, so it releases first: the client and its
|
|
@@ -147,7 +159,7 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
|
147
159
|
exited = exit.promise;
|
|
148
160
|
|
|
149
161
|
capture = booted.onData((data) => {
|
|
150
|
-
firstOutputAt ??=
|
|
162
|
+
firstOutputAt ??= now();
|
|
151
163
|
out += data;
|
|
152
164
|
});
|
|
153
165
|
|
|
@@ -171,7 +183,7 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
|
171
183
|
},
|
|
172
184
|
|
|
173
185
|
async waitFor(needle: string, ms = 4000): Promise<void> {
|
|
174
|
-
const start =
|
|
186
|
+
const start = now();
|
|
175
187
|
|
|
176
188
|
// The client gives its daemon 8 seconds to answer before it draws an
|
|
177
189
|
// error, so the boot phase waits that long plus 1 second for the
|
|
@@ -190,7 +202,7 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
|
190
202
|
|
|
191
203
|
return firstOutputAt;
|
|
192
204
|
},
|
|
193
|
-
{ timeoutMs: bootMs, intervalMs: 50 },
|
|
205
|
+
{ timeoutMs: bootMs, intervalMs: 50, now, wait },
|
|
194
206
|
);
|
|
195
207
|
|
|
196
208
|
await waitFor(
|
|
@@ -201,7 +213,12 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
|
201
213
|
);
|
|
202
214
|
}
|
|
203
215
|
},
|
|
204
|
-
{
|
|
216
|
+
{
|
|
217
|
+
timeoutMs: Math.max(0, Math.max(start, firstAt) + ms - now()),
|
|
218
|
+
intervalMs: 50,
|
|
219
|
+
now,
|
|
220
|
+
wait,
|
|
221
|
+
},
|
|
205
222
|
);
|
|
206
223
|
},
|
|
207
224
|
|
|
@@ -228,7 +245,7 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
|
|
|
228
245
|
);
|
|
229
246
|
}
|
|
230
247
|
},
|
|
231
|
-
{ timeoutMs: ms },
|
|
248
|
+
{ timeoutMs: ms, now, wait },
|
|
232
249
|
);
|
|
233
250
|
},
|
|
234
251
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a listener on a kernel-chosen port of every given address on this
|
|
3
|
+
* host and closes each at once, and returns whether every listener could be
|
|
4
|
+
* created, in place of the throw a failed one raises. A host without an
|
|
5
|
+
* address, such as stock macOS for any loopback address past 127.0.0.1,
|
|
6
|
+
* refuses the listener with EADDRNOTAVAIL.
|
|
7
|
+
*/
|
|
8
|
+
export function tryCreateListeners(hostnames: readonly string[]): boolean {
|
|
9
|
+
for (const hostname of hostnames) {
|
|
10
|
+
try {
|
|
11
|
+
const listener = Bun.listen({ hostname, port: 0, socket: { data() {} } });
|
|
12
|
+
|
|
13
|
+
listener.stop(true);
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tries to bind a socket to every given address on this host, by listening
|
|
3
|
-
* on a kernel-chosen port of each and closing it at once, and returns whether
|
|
4
|
-
* every bind succeeded. A host
|
|
5
|
-
* without an address, such as stock macOS for any loopback address past
|
|
6
|
-
* 127.0.0.1, refuses the bind with EADDRNOTAVAIL.
|
|
7
|
-
*/
|
|
8
|
-
export function tryBindAddresses(hostnames: readonly string[]): boolean {
|
|
9
|
-
for (const hostname of hostnames) {
|
|
10
|
-
try {
|
|
11
|
-
const listener = Bun.listen({ hostname, port: 0, socket: { data() {} } });
|
|
12
|
-
|
|
13
|
-
listener.stop(true);
|
|
14
|
-
} catch {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return true;
|
|
20
|
-
}
|