@zgeoff/atc 2.24.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +272 -25
- package/src/client/collect-agent-picks.ts +2 -5
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +66 -7
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +84 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/format-log-field.ts +38 -0
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/make-non-blocking-log.ts +91 -0
- package/src/daemon/refusal-log.ts +163 -0
- package/src/daemon/restore-fleet.ts +16 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/start-tcp-listener.ts +47 -1
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +17 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -34,8 +34,14 @@ interface RequestScope {
|
|
|
34
34
|
readonly keyNamespace: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// The requests that act on the whole daemon,
|
|
38
|
-
|
|
37
|
+
// The requests that act on the whole daemon, or on the credentials a
|
|
38
|
+
// session's host may use, which only its owner may make.
|
|
39
|
+
const OWNER_METHODS: ReadonlySet<string> = new Set([
|
|
40
|
+
'daemon.quit',
|
|
41
|
+
'fleet.restore',
|
|
42
|
+
'session.auth.revoke',
|
|
43
|
+
'session.auth.rebind',
|
|
44
|
+
]);
|
|
39
45
|
|
|
40
46
|
// What a limited connection is sent for one event, and the sessions that
|
|
41
47
|
// event moved out of its view.
|
|
@@ -68,9 +74,15 @@ interface PeerSocket extends SocketWriter {
|
|
|
68
74
|
export interface TCPPeer {
|
|
69
75
|
readonly verifyHandshake: (presented: string | null) => Promise<string | null>;
|
|
70
76
|
|
|
71
|
-
// Counts a line other than a handshake,
|
|
72
|
-
// failed handshake from the peer's
|
|
73
|
-
|
|
77
|
+
// Counts a line other than a handshake, or one over the size cap, sent
|
|
78
|
+
// before a handshake passed, as a failed handshake from the peer's
|
|
79
|
+
// address.
|
|
80
|
+
readonly recordFailure: (reason: 'unexpected_line' | 'line_too_long') => void;
|
|
81
|
+
|
|
82
|
+
// Records a handshake or a request refused for a principal the config
|
|
83
|
+
// does not list. The principal itself is never logged: an unlisted one
|
|
84
|
+
// is whatever the peer sent, a credential included.
|
|
85
|
+
readonly recordRefusedPrincipal: () => void;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
export class DaemonConnection {
|
|
@@ -214,7 +226,7 @@ export class DaemonConnection {
|
|
|
214
226
|
const oversized = this.lines.pendingLength + chunk.length > MAX_LINE;
|
|
215
227
|
|
|
216
228
|
if (oversized && this.isUnauthenticatedTCP()) {
|
|
217
|
-
this.tcp?.recordFailure();
|
|
229
|
+
this.tcp?.recordFailure('line_too_long');
|
|
218
230
|
this.peer.end();
|
|
219
231
|
|
|
220
232
|
return;
|
|
@@ -331,7 +343,7 @@ export class DaemonConnection {
|
|
|
331
343
|
const isHello = decoded.kind === 'request' && decoded.msg.m === 'daemon.hello';
|
|
332
344
|
|
|
333
345
|
if (this.tcpHelloSent || !isHello) {
|
|
334
|
-
this.tcp?.recordFailure();
|
|
346
|
+
this.tcp?.recordFailure('unexpected_line');
|
|
335
347
|
|
|
336
348
|
return 'close';
|
|
337
349
|
}
|
|
@@ -380,6 +392,7 @@ export class DaemonConnection {
|
|
|
380
392
|
}
|
|
381
393
|
|
|
382
394
|
if (!this.ctx.hasListedPrincipal(req.as)) {
|
|
395
|
+
this.tcp?.recordRefusedPrincipal();
|
|
383
396
|
this.sendErr(req.id, 'unauthorized', `principal '${req.as}' is not listed in principals`);
|
|
384
397
|
|
|
385
398
|
return;
|
|
@@ -648,6 +661,48 @@ export class DaemonConnection {
|
|
|
648
661
|
|
|
649
662
|
return;
|
|
650
663
|
}
|
|
664
|
+
case 'session.auth.revoke': {
|
|
665
|
+
const parsed = parseRequestParams('session.auth.revoke', req.p);
|
|
666
|
+
|
|
667
|
+
if (!parsed.ok) {
|
|
668
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
669
|
+
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const id = parsed.data.session;
|
|
674
|
+
|
|
675
|
+
const revoked = await ctx.revokeSessionAuth(id);
|
|
676
|
+
|
|
677
|
+
if (revoked) {
|
|
678
|
+
this.sendOk(req.id, { revoked: true });
|
|
679
|
+
} else {
|
|
680
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
case 'session.auth.rebind': {
|
|
686
|
+
const parsed = parseRequestParams('session.auth.rebind', req.p);
|
|
687
|
+
|
|
688
|
+
if (!parsed.ok) {
|
|
689
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
690
|
+
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const id = parsed.data.session;
|
|
695
|
+
|
|
696
|
+
const revision = await ctx.updateSessionAuth(id);
|
|
697
|
+
|
|
698
|
+
if (revision === null) {
|
|
699
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
700
|
+
} else {
|
|
701
|
+
this.sendOk(req.id, { revision });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
651
706
|
case 'session.resumeCommand': {
|
|
652
707
|
const parsed = parseRequestParams('session.resumeCommand', req.p);
|
|
653
708
|
|
|
@@ -901,6 +956,8 @@ export class DaemonConnection {
|
|
|
901
956
|
throw refusal;
|
|
902
957
|
}
|
|
903
958
|
|
|
959
|
+
ctx.requireAgentTarget(agent, target);
|
|
960
|
+
|
|
904
961
|
const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
|
|
905
962
|
|
|
906
963
|
if (!overrides.ok) {
|
|
@@ -1619,6 +1676,8 @@ export class DaemonConnection {
|
|
|
1619
1676
|
const principal = parsedHello.data.principal ?? null;
|
|
1620
1677
|
|
|
1621
1678
|
if (principal !== null && !this.ctx.hasListedPrincipal(principal)) {
|
|
1679
|
+
tcp.recordRefusedPrincipal();
|
|
1680
|
+
|
|
1622
1681
|
this.sendErr(
|
|
1623
1682
|
req.id,
|
|
1624
1683
|
'unauthorized',
|
|
@@ -180,6 +180,10 @@ export interface DaemonContext {
|
|
|
180
180
|
// one whose provider cannot both transfer an archive and run a command.
|
|
181
181
|
readonly requireWorkspaceTarget: (target: string) => void;
|
|
182
182
|
|
|
183
|
+
// Throws the refusal for an agent that takes its credential from impd's
|
|
184
|
+
// broker on a target whose provider reaches no broker.
|
|
185
|
+
readonly requireAgentTarget: (agent: AgentID, target: string) => void;
|
|
186
|
+
|
|
183
187
|
// The source the daemon offers the spawn picker under an id, or null when
|
|
184
188
|
// it offers none under it.
|
|
185
189
|
readonly findSource: (id: string) => SourceProvider | null;
|
|
@@ -211,6 +215,14 @@ export interface DaemonContext {
|
|
|
211
215
|
id: SessionID,
|
|
212
216
|
confirmToken: string | undefined,
|
|
213
217
|
) => Promise<ForgetResult | 'missing'>;
|
|
218
|
+
|
|
219
|
+
// Withdraws the grants of the runtime auth binding on a session's host,
|
|
220
|
+
// and answers false for no such session.
|
|
221
|
+
readonly revokeSessionAuth: (id: SessionID) => Promise<boolean>;
|
|
222
|
+
|
|
223
|
+
// Binds a session's host to its agent's current auth selection, and
|
|
224
|
+
// answers the new revision, or null for no such session.
|
|
225
|
+
readonly updateSessionAuth: (id: SessionID) => Promise<number | null>;
|
|
214
226
|
readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
|
|
215
227
|
readonly quitDaemon: () => void;
|
|
216
228
|
readonly ackSession: (id: SessionID) => boolean;
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { loadListenerTokens } from './load-listener-tokens';
|
|
|
69
69
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
70
70
|
import { makeHookRunner } from './make-hook-runner';
|
|
71
71
|
import type { HookScope } from './make-hook-runner';
|
|
72
|
+
import { makeNonBlockingLog } from './make-non-blocking-log';
|
|
72
73
|
import { materializeWorkspace } from './materialize-workspace';
|
|
73
74
|
import { mintMessageID } from './mint-message-id';
|
|
74
75
|
import { mintSessionID } from './mint-session-id';
|
|
@@ -77,6 +78,7 @@ import { PermissionRegistry } from './permission-registry';
|
|
|
77
78
|
import { requireGitTransports } from './require-git-transports';
|
|
78
79
|
import { restoreFleet } from './restore-fleet';
|
|
79
80
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
81
|
+
import { RuntimeAuthBinder } from './runtime-auth-binder';
|
|
80
82
|
import { ScreenModel } from './screen-model';
|
|
81
83
|
import { SessionRuntime } from './session-runtime';
|
|
82
84
|
import { SessionManager } from './sessions';
|
|
@@ -191,6 +193,16 @@ interface ListenOptions {
|
|
|
191
193
|
// How many delayed handshakes may wait at once across every address; 64
|
|
192
194
|
// when unset.
|
|
193
195
|
readonly maxDelayedHandshakes?: number;
|
|
196
|
+
|
|
197
|
+
// The clock the refusal log reads, Date.now when unset, and how long it
|
|
198
|
+
// folds repeated refusals from one peer into one line; a minute when
|
|
199
|
+
// unset.
|
|
200
|
+
readonly now?: () => number;
|
|
201
|
+
readonly refusalLogIntervalMs?: number;
|
|
202
|
+
|
|
203
|
+
// How many peers and kinds of refusal the refusal log tracks at once;
|
|
204
|
+
// 1024 when unset.
|
|
205
|
+
readonly maxRefusalWindows?: number;
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
export interface DaemonHandle {
|
|
@@ -230,6 +242,17 @@ const HANDSHAKE_FAILURE_DELAY_MS = 10_000;
|
|
|
230
242
|
// refused at once.
|
|
231
243
|
const MAX_DELAYED_HANDSHAKES = 64;
|
|
232
244
|
|
|
245
|
+
// How long the TCP listener folds repeated refusals from one peer into one
|
|
246
|
+
// log line.
|
|
247
|
+
const REFUSAL_LOG_INTERVAL_MS = 60_000;
|
|
248
|
+
|
|
249
|
+
// How many peers and kinds of refusal the TCP listener's refusal log tracks
|
|
250
|
+
// at once.
|
|
251
|
+
const MAX_REFUSAL_WINDOWS = 1024;
|
|
252
|
+
|
|
253
|
+
// Where the TCP listener logs when the daemon is given no log.
|
|
254
|
+
const STDERR_FD = 2;
|
|
255
|
+
|
|
233
256
|
// How long startup waits for a daemon that is shutting down to release the
|
|
234
257
|
// state lock before refusing to start.
|
|
235
258
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -278,6 +301,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
278
301
|
// that stopped mid-effect is settled, so no retry can race either.
|
|
279
302
|
await store.reconcileMaterializations(Date.now());
|
|
280
303
|
await store.reconcileIdempotencyKeys(Date.now());
|
|
304
|
+
await store.reconcileAuthBindings(Date.now());
|
|
281
305
|
|
|
282
306
|
await tryRemoveExpiredIdempotencyKeys(store);
|
|
283
307
|
|
|
@@ -317,6 +341,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
317
341
|
mgr.log = opts.log;
|
|
318
342
|
}
|
|
319
343
|
|
|
344
|
+
const authBinder = new RuntimeAuthBinder(store);
|
|
345
|
+
|
|
346
|
+
mgr.authBinder = authBinder;
|
|
347
|
+
|
|
348
|
+
// Settled in the background, since it reaches impd: each binding a
|
|
349
|
+
// stopped daemon left mid-change stays blocked until it is settled.
|
|
350
|
+
void tryReconcileAuthBindings(authBinder, store, targetsByID, mgr.log);
|
|
351
|
+
|
|
320
352
|
const clients = new Set<DaemonConnection>();
|
|
321
353
|
|
|
322
354
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -1272,7 +1304,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1272
1304
|
arch: process.arch,
|
|
1273
1305
|
build: opts.build,
|
|
1274
1306
|
},
|
|
1275
|
-
agents: buildAgentList(
|
|
1307
|
+
agents: buildAgentList(
|
|
1308
|
+
mgr.collectAdapters(),
|
|
1309
|
+
(bin) => Bun.which(bin) !== null,
|
|
1310
|
+
targets.some((target) => target.provider?.brokerAuth !== undefined),
|
|
1311
|
+
),
|
|
1276
1312
|
targets: buildTargetList(targets, defaultTarget),
|
|
1277
1313
|
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
1278
1314
|
configRevision,
|
|
@@ -1324,6 +1360,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1324
1360
|
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1325
1361
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1326
1362
|
},
|
|
1363
|
+
requireAgentTarget: (agent, target) => {
|
|
1364
|
+
mgr.requireAgentTarget(agent, target);
|
|
1365
|
+
},
|
|
1327
1366
|
findSource: (id) => sources.find((source) => source.id === id) ?? null,
|
|
1328
1367
|
collectAlternateGitURLs: (url) => [
|
|
1329
1368
|
...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
|
|
@@ -1446,6 +1485,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1446
1485
|
|
|
1447
1486
|
return { forgotten: true, destroyed };
|
|
1448
1487
|
},
|
|
1488
|
+
revokeSessionAuth: (id) => mgr.revokeAuth(id),
|
|
1489
|
+
updateSessionAuth: (id) => mgr.updateAuth(id),
|
|
1449
1490
|
ejectSession: (id, prompt) => {
|
|
1450
1491
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1451
1492
|
|
|
@@ -1494,6 +1535,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1494
1535
|
return 'no_transcript';
|
|
1495
1536
|
}
|
|
1496
1537
|
|
|
1538
|
+
const listed = mgr.sessions.find((s) => s.id === id);
|
|
1539
|
+
|
|
1540
|
+
// Refused before the headless run stops, so a refusal leaves it as
|
|
1541
|
+
// it was.
|
|
1542
|
+
if (listed !== undefined) {
|
|
1543
|
+
mgr.requireAgentTarget(listed.agent, listed.target);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1497
1546
|
const runtime = runtimes.get(id);
|
|
1498
1547
|
|
|
1499
1548
|
runtime?.stopHeadlessRun();
|
|
@@ -2010,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2010
2059
|
return connection;
|
|
2011
2060
|
},
|
|
2012
2061
|
closeConnection: detachConnection,
|
|
2062
|
+
log: opts.log ?? makeNonBlockingLog(STDERR_FD),
|
|
2063
|
+
now: opts.listen.now ?? Date.now,
|
|
2064
|
+
refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
|
|
2065
|
+
maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
|
|
2013
2066
|
});
|
|
2014
2067
|
} catch (error) {
|
|
2015
2068
|
await releaseResources();
|
|
@@ -2197,6 +2250,36 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
|
|
|
2197
2250
|
return true;
|
|
2198
2251
|
}
|
|
2199
2252
|
|
|
2253
|
+
// Settles the runtime auth bindings a stopped daemon left mid-change, each
|
|
2254
|
+
// through its target's broker; a host with a fleet entry is listed, and a
|
|
2255
|
+
// failure is logged and leaves its binding blocked.
|
|
2256
|
+
async function tryReconcileAuthBindings(
|
|
2257
|
+
binder: RuntimeAuthBinder,
|
|
2258
|
+
store: StateStore,
|
|
2259
|
+
targets: ReadonlyMap<string, ExecutionTarget>,
|
|
2260
|
+
log: (line: string) => void,
|
|
2261
|
+
): Promise<boolean> {
|
|
2262
|
+
try {
|
|
2263
|
+
const fleet = await store.loadFleet();
|
|
2264
|
+
|
|
2265
|
+
const listed = new Set(fleet.map((entry) => entry.hostKey ?? entry.sessionID));
|
|
2266
|
+
|
|
2267
|
+
await binder.reconcileBindings(
|
|
2268
|
+
(target) => targets.get(target)?.provider?.brokerAuth ?? null,
|
|
2269
|
+
listed,
|
|
2270
|
+
log,
|
|
2271
|
+
);
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
log(
|
|
2274
|
+
`atc could not settle runtime auth bindings (${error instanceof Error ? error.message : String(error)})`,
|
|
2275
|
+
);
|
|
2276
|
+
|
|
2277
|
+
return false;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
return true;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2200
2283
|
interface ConfirmToken {
|
|
2201
2284
|
readonly session: SessionID;
|
|
2202
2285
|
readonly expiresAt: number;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import type { BrokerAuthHost } from './broker-auth-host';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* The host a session's harness runs on: it starts a process in a
|
|
3
6
|
* pseudo-terminal and, as its capabilities declare, unpacks files into its
|
|
@@ -22,6 +25,11 @@ export interface ExecutionProvider {
|
|
|
22
25
|
// host has none. Absent on the daemon's own machine.
|
|
23
26
|
readonly guest?: GuestLayout;
|
|
24
27
|
|
|
28
|
+
// How a harness here can take its credential from impd's broker instead
|
|
29
|
+
// of holding it. Absent on a host with no broker, which never starts a
|
|
30
|
+
// session that needs one.
|
|
31
|
+
readonly brokerAuth?: BrokerAuthHost;
|
|
32
|
+
|
|
25
33
|
// Readies the host a harness is about to start on: a remote host is
|
|
26
34
|
// created when missing, woken when asleep, and held awake while its
|
|
27
35
|
// harnesses run. Rejects with the refusal before any harness starts.
|
|
@@ -41,8 +49,12 @@ export interface ExecutionProvider {
|
|
|
41
49
|
|
|
42
50
|
// Puts a host to sleep with every harness on it kept inside, so a revive
|
|
43
51
|
// finds each one as it was. Rejects with `host_leased` when another owner
|
|
44
|
-
// keeps the host awake, and leaves the host as it was then.
|
|
45
|
-
|
|
52
|
+
// keeps the host awake, and leaves the host as it was then. A sleep runs
|
|
53
|
+
// after any readying of the host before it, and a readying waits for it.
|
|
54
|
+
// isIdle makes it a sleep of an idle host only: it is checked when the
|
|
55
|
+
// sleep starts and again just before the host sleeps, and a host that a
|
|
56
|
+
// harness or a readying keeps busy stays awake with `host_unavailable`.
|
|
57
|
+
readonly suspendHost: (host: string, isIdle?: () => boolean) => Promise<void>;
|
|
46
58
|
|
|
47
59
|
// Deletes a host and everything on it, harnesses included. Nothing brings
|
|
48
60
|
// a destroyed host back.
|
|
@@ -65,6 +77,10 @@ export interface HostRequest {
|
|
|
65
77
|
// Whether the harness about to start needs atc inside the host, which a
|
|
66
78
|
// provider that ships its own binary installs when missing.
|
|
67
79
|
readonly installATC?: boolean;
|
|
80
|
+
|
|
81
|
+
// Whether the daemon has nothing running or starting on the host, which
|
|
82
|
+
// the provider checks before it gives the host's lease back on its own.
|
|
83
|
+
readonly isIdle?: () => boolean;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export interface GuestLayout {
|
|
@@ -132,6 +148,20 @@ export interface HarnessSpec {
|
|
|
132
148
|
readonly cols: number;
|
|
133
149
|
readonly rows: number;
|
|
134
150
|
|
|
151
|
+
// Whether the harness must not start unless the host's credential broker
|
|
152
|
+
// is ready: the host refuses the start and runs nothing otherwise, on
|
|
153
|
+
// every start, a revive's included.
|
|
154
|
+
readonly requireBroker?: boolean;
|
|
155
|
+
|
|
156
|
+
// Admits each start or attach of a harness that requires the broker by
|
|
157
|
+
// calling send, which hands the request to the host, or rejects with the
|
|
158
|
+
// refusal that ends the harness instead, sending nothing. send gets the
|
|
159
|
+
// admission's ticket.
|
|
160
|
+
readonly admit?: (
|
|
161
|
+
kind: 'start' | 'attach',
|
|
162
|
+
send: (ticket: LaunchTicket) => void,
|
|
163
|
+
) => Promise<void>;
|
|
164
|
+
|
|
135
165
|
// Takes each connection a process of the harness opens to the daemon. A
|
|
136
166
|
// remote provider relays them from a socket inside the host that serves
|
|
137
167
|
// this harness alone, and points the harness's ATC_SOCKET at it.
|
|
@@ -143,6 +173,18 @@ export interface HarnessSpec {
|
|
|
143
173
|
* lines: each line the process writes arrives whole, and each line the
|
|
144
174
|
* daemon writes reaches the process in order.
|
|
145
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* One admitted request of a harness behind the broker. check runs just
|
|
178
|
+
* before the request goes out and returns the refusal that stops it
|
|
179
|
+
* unsent, or null to let it go; release gives the admission up once its
|
|
180
|
+
* connection ends without the request going out. Each runs at most once
|
|
181
|
+
* to any effect.
|
|
182
|
+
*/
|
|
183
|
+
export interface LaunchTicket {
|
|
184
|
+
readonly check: () => DaemonError | null;
|
|
185
|
+
readonly release: () => void;
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
export interface HarnessRelay {
|
|
147
189
|
readonly onLine: (listener: (line: string) => void) => void;
|
|
148
190
|
readonly onClose: (listener: () => void) => void;
|
|
@@ -176,6 +218,12 @@ export interface HarnessHandle {
|
|
|
176
218
|
// a provider that can send one; absent on a provider that cannot.
|
|
177
219
|
readonly killForced?: () => void;
|
|
178
220
|
|
|
221
|
+
// Resolves once the harness's process has started, or a running one was
|
|
222
|
+
// attached, and rejects with the refusal when the host ends the harness
|
|
223
|
+
// or the daemon lets go of it first. Absent on a provider whose harness
|
|
224
|
+
// starts at once.
|
|
225
|
+
readonly waitForStart?: () => Promise<void>;
|
|
226
|
+
|
|
179
227
|
// Resolves true once the harness's process has exited, and false when the
|
|
180
228
|
// wait runs out first or the harness stops being followed without an
|
|
181
229
|
// exit, such as a host that went to sleep with the process inside.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The most characters of a field a log line keeps; a longer one is cut and
|
|
2
|
+
// ends in `...`.
|
|
3
|
+
const MAX_FIELD_CHARS = 64;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Formats a value for one `key=value` field of a log line. Every character
|
|
7
|
+
* outside printable ASCII, and a space, `"`, `=`, or `\`, is written as a
|
|
8
|
+
* `\u{hex}` escape, so a value from the network can neither move the
|
|
9
|
+
* terminal, start a new line, nor forge another field. The value is cut to
|
|
10
|
+
* 64 characters before it is escaped.
|
|
11
|
+
*/
|
|
12
|
+
export function formatLogField(value: string): string {
|
|
13
|
+
let formatted = '';
|
|
14
|
+
let kept = 0;
|
|
15
|
+
|
|
16
|
+
// Iterating a string steps by code point, so a character outside the
|
|
17
|
+
// basic plane counts and escapes as one.
|
|
18
|
+
for (const char of value) {
|
|
19
|
+
if (kept === MAX_FIELD_CHARS) {
|
|
20
|
+
return `${formatted}...`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
formatted += isPlainChar(char) ? char : formatEscape(char);
|
|
24
|
+
kept++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return formatted;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainChar(char: string): boolean {
|
|
31
|
+
const code = char.codePointAt(0) ?? 0;
|
|
32
|
+
|
|
33
|
+
return code > 0x20 && code < 0x7f && char !== '"' && char !== '=' && char !== '\\';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatEscape(char: string): string {
|
|
37
|
+
return `\\u{${(char.codePointAt(0) ?? 0).toString(16)}}`;
|
|
38
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
|
|
2
|
-
import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
|
|
2
|
+
import type { ExecOutcome, ExecSessionOptions, ImpClient } from '@zgeoff/imp-client';
|
|
3
3
|
import { isRecord } from '../shared/report';
|
|
4
4
|
import type {
|
|
5
5
|
ImpCommand,
|
|
6
6
|
ImpCommandResult,
|
|
7
7
|
ImpCreateSpec,
|
|
8
|
+
ImpExecRequirement,
|
|
8
9
|
ImpFeatures,
|
|
9
10
|
ImpIdentity,
|
|
10
11
|
ImpLease,
|
|
@@ -63,6 +64,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
63
64
|
leases: features?.['leases'] === true,
|
|
64
65
|
grantableTokens: features?.['grantableTokens'] === true,
|
|
65
66
|
secretRebind: features?.['secretRebind'] === true,
|
|
67
|
+
execRequire: features?.['execRequire'] === true,
|
|
66
68
|
};
|
|
67
69
|
};
|
|
68
70
|
|
|
@@ -199,6 +201,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
199
201
|
readonly openSession = (
|
|
200
202
|
request: ImpSessionRequest,
|
|
201
203
|
handlers: ImpSessionHandlers,
|
|
204
|
+
gate?: () => boolean,
|
|
202
205
|
): ImpSessionConnection => {
|
|
203
206
|
const token = this.tryReadToken();
|
|
204
207
|
|
|
@@ -217,34 +220,14 @@ export class ImpClientPort implements ImpPort {
|
|
|
217
220
|
const session = openExecSession({
|
|
218
221
|
baseUrl: this.url,
|
|
219
222
|
token,
|
|
220
|
-
start:
|
|
221
|
-
request.kind === 'start'
|
|
222
|
-
? {
|
|
223
|
-
name: request.name,
|
|
224
|
-
session: request.session,
|
|
225
|
-
argv: request.argv,
|
|
226
|
-
tty: true,
|
|
227
|
-
env: request.env,
|
|
228
|
-
cwd: request.cwd,
|
|
229
|
-
cols: request.cols,
|
|
230
|
-
rows: request.rows,
|
|
231
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
232
|
-
}
|
|
233
|
-
: {
|
|
234
|
-
name: request.name,
|
|
235
|
-
session: request.session,
|
|
236
|
-
cols: request.cols,
|
|
237
|
-
rows: request.rows,
|
|
238
|
-
wake: request.wake,
|
|
239
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
240
|
-
},
|
|
223
|
+
start: buildExecStart(request),
|
|
241
224
|
onStarted: (started) => {
|
|
242
225
|
handlers.onStarted({ created: started.created, output: started.output });
|
|
243
226
|
},
|
|
244
227
|
onOutput: (_channel, data) => {
|
|
245
228
|
handlers.onOutput(data);
|
|
246
229
|
},
|
|
247
|
-
connect: (url, headers) =>
|
|
230
|
+
connect: (url, headers) => openGatedSocket(url, headers, gate),
|
|
248
231
|
});
|
|
249
232
|
|
|
250
233
|
return {
|
|
@@ -389,6 +372,41 @@ function toPortError(error: unknown): ImpPortError {
|
|
|
389
372
|
return new ImpPortError('UNREACHABLE', message);
|
|
390
373
|
}
|
|
391
374
|
|
|
375
|
+
// A start or an attach as the client takes it, with the requirements impd
|
|
376
|
+
// checks before the command runs or the attach joins it.
|
|
377
|
+
type ExecStartWithRequire = ExecSessionOptions['start'] & {
|
|
378
|
+
readonly require?: readonly ImpExecRequirement[];
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// The client sends every field of a start or an attach as it is, so a list
|
|
382
|
+
// of requirements reaches impd though the client's types lack it.
|
|
383
|
+
function buildExecStart(request: ImpSessionRequest): ExecStartWithRequire {
|
|
384
|
+
if (request.kind === 'attach') {
|
|
385
|
+
return {
|
|
386
|
+
name: request.name,
|
|
387
|
+
session: request.session,
|
|
388
|
+
cols: request.cols,
|
|
389
|
+
rows: request.rows,
|
|
390
|
+
wake: request.wake,
|
|
391
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
392
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
name: request.name,
|
|
398
|
+
session: request.session,
|
|
399
|
+
argv: request.argv,
|
|
400
|
+
tty: true,
|
|
401
|
+
env: request.env,
|
|
402
|
+
cwd: request.cwd,
|
|
403
|
+
cols: request.cols,
|
|
404
|
+
rows: request.rows,
|
|
405
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
406
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
392
410
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
393
411
|
async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
|
|
394
412
|
const ended = await outcome;
|
|
@@ -469,3 +487,33 @@ function openRelay(accept: RelayAccept): ImpRelayConnection {
|
|
|
469
487
|
},
|
|
470
488
|
};
|
|
471
489
|
}
|
|
490
|
+
|
|
491
|
+
// The client sends its request from the socket's open listener. The gate
|
|
492
|
+
// listens first, so a closed gate, or one that throws, stops that
|
|
493
|
+
// listener and closes the socket before anything goes out.
|
|
494
|
+
function openGatedSocket(
|
|
495
|
+
url: string,
|
|
496
|
+
headers: Readonly<Record<string, string>>,
|
|
497
|
+
gate: (() => boolean) | undefined,
|
|
498
|
+
): WebSocket {
|
|
499
|
+
const socket = new WebSocket(url, { headers });
|
|
500
|
+
|
|
501
|
+
if (gate !== undefined) {
|
|
502
|
+
socket.addEventListener('open', (event) => {
|
|
503
|
+
if (!tryPassGate(gate)) {
|
|
504
|
+
event.stopImmediatePropagation();
|
|
505
|
+
socket.close(1000, 'closed before sending');
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return socket;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function tryPassGate(gate: () => boolean): boolean {
|
|
514
|
+
try {
|
|
515
|
+
return gate();
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|