@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
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import type {
|
|
3
3
|
AgentAdapter,
|
|
4
|
+
GuestPaths,
|
|
4
5
|
SpawnOptions,
|
|
5
6
|
SpawnOverrides,
|
|
6
7
|
SpawnPlan,
|
|
@@ -17,12 +18,16 @@ import type { AgentSessionID } from '../shared/agent-session-id';
|
|
|
17
18
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
18
19
|
import { socketPath, statusFile } from '../shared/config';
|
|
19
20
|
import type { DaemonID } from '../shared/daemon-id';
|
|
21
|
+
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
20
22
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
21
23
|
import type { SessionID } from '../shared/session-id';
|
|
22
24
|
import { truncateDetail } from '../shared/truncate-detail';
|
|
23
25
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
24
26
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
25
27
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
28
|
+
import type { BrokerAuthHost } from './broker-auth-host';
|
|
29
|
+
import { buildAuthBinding } from './build-auth-binding';
|
|
30
|
+
import type { AuthBinding } from './build-auth-binding';
|
|
26
31
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
27
32
|
import { buildSessionLifecycle } from './build-session-lifecycle';
|
|
28
33
|
import type { SessionLifecycle } from './build-session-lifecycle';
|
|
@@ -33,12 +38,14 @@ import type {
|
|
|
33
38
|
ExecutionProvider,
|
|
34
39
|
HarnessHandle,
|
|
35
40
|
HarnessRelay,
|
|
41
|
+
HarnessSpec,
|
|
36
42
|
} from './execution-provider';
|
|
37
43
|
import { findExecutionRefusal } from './find-execution-refusal';
|
|
38
44
|
import type { BridgeBinding } from './is-binding-current';
|
|
39
45
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
40
46
|
import { mintSessionID } from './mint-session-id';
|
|
41
47
|
import { pickSessionState } from './pick-session-state';
|
|
48
|
+
import type { RuntimeAuthBinder } from './runtime-auth-binder';
|
|
42
49
|
|
|
43
50
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
44
51
|
|
|
@@ -186,6 +193,36 @@ const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
|
186
193
|
// once after its kill and once more after a forced kill.
|
|
187
194
|
const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
|
|
188
195
|
|
|
196
|
+
// How a harness takes its credential from impd's broker: the broker the
|
|
197
|
+
// provider reaches, and the binding the agent's selection resolves to now.
|
|
198
|
+
interface HarnessAuth {
|
|
199
|
+
readonly host: BrokerAuthHost;
|
|
200
|
+
readonly binding: AuthBinding;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A harness's auth as its setup applies it: a spawn's own new host creates
|
|
204
|
+
// the binding, and every other launch verifies the one its host holds.
|
|
205
|
+
interface HarnessAuthSetup extends HarnessAuth {
|
|
206
|
+
readonly mode: 'create' | 'verify';
|
|
207
|
+
readonly targetIdentity: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A harness as its setup plans it: the agent's spawn, and the variables
|
|
211
|
+
// the agent's guest plan starts the harness with, which no variable atc
|
|
212
|
+
// adds overrides.
|
|
213
|
+
interface HarnessPlan extends SpawnPlan {
|
|
214
|
+
readonly env: Readonly<Record<string, string>>;
|
|
215
|
+
|
|
216
|
+
// What a launch behind the broker was planned under, which its host's
|
|
217
|
+
// binding must still admit when each request goes out; null without
|
|
218
|
+
// runtime auth.
|
|
219
|
+
readonly admission: {
|
|
220
|
+
readonly revision: number;
|
|
221
|
+
readonly hash: string;
|
|
222
|
+
readonly attemptID: string | null;
|
|
223
|
+
} | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
189
226
|
// The target a session runs on and the identity it is bound to there; null
|
|
190
227
|
// binds a new session to the target as it stands now.
|
|
191
228
|
interface TargetBinding {
|
|
@@ -218,6 +255,10 @@ export class SessionManager {
|
|
|
218
255
|
console.error(line);
|
|
219
256
|
};
|
|
220
257
|
|
|
258
|
+
// Binds the runtime auth of each host whose agent takes its credential
|
|
259
|
+
// from impd's broker; with none, every such start is refused.
|
|
260
|
+
authBinder: RuntimeAuthBinder | null = null;
|
|
261
|
+
|
|
221
262
|
// Whether any registered adapter has a screen detector, decided once at
|
|
222
263
|
// construction since the registry never changes afterward. Lets a hot path
|
|
223
264
|
// that only cares about this answer skip walking live sessions to find it.
|
|
@@ -237,6 +278,11 @@ export class SessionManager {
|
|
|
237
278
|
// of the same session does not start a second harness.
|
|
238
279
|
private readonly adopting = new Set<SessionID>();
|
|
239
280
|
|
|
281
|
+
// The launches readying each host, by host key. A launch counts from its
|
|
282
|
+
// setup until its harness spawns, which follows the setup with no wait,
|
|
283
|
+
// so a host with a launch in flight is never idle.
|
|
284
|
+
private readonly readying = new Map<SessionID, number>();
|
|
285
|
+
|
|
240
286
|
// Sessions dropped from the list on purpose whose rows the next fleet
|
|
241
287
|
// write deletes; each stays here until a write carrying it lands.
|
|
242
288
|
private readonly removedIDs = new Set<SessionID>();
|
|
@@ -476,6 +522,25 @@ export class SessionManager {
|
|
|
476
522
|
return desired === 'sleep' ? 'asleep' : 'unknown';
|
|
477
523
|
}
|
|
478
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Throws the refusal for an agent that takes its credential from impd's
|
|
527
|
+
* broker on a target whose provider reaches no broker, before a spawn
|
|
528
|
+
* or an adopt does any work.
|
|
529
|
+
*/
|
|
530
|
+
requireAgentTarget(agent: AgentID, target: string): void {
|
|
531
|
+
const adapter = this.findAdapter(agent);
|
|
532
|
+
|
|
533
|
+
if (adapter === null || (adapter.findAuthSelection?.() ?? null) === null) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const provider = this.targets.get(target)?.provider ?? null;
|
|
538
|
+
|
|
539
|
+
if (provider?.brokerAuth === undefined || this.authBinder === null) {
|
|
540
|
+
throw buildBrokerTargetRefusal(agent, target);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
479
544
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
480
545
|
// same agent session id. On a remote host the host wakes first, and a
|
|
481
546
|
// harness still running inside it is attached rather than started again;
|
|
@@ -507,18 +572,32 @@ export class SessionManager {
|
|
|
507
572
|
}
|
|
508
573
|
|
|
509
574
|
const provider = this.requireExecution(s, 'spawn').provider;
|
|
575
|
+
const auth = this.resolveHarnessAuth(adapter, provider, s.target);
|
|
576
|
+
|
|
577
|
+
const authSetup: HarnessAuthSetup | null =
|
|
578
|
+
auth === null ? null : { ...auth, mode: 'verify', targetIdentity: s.targetIdentity };
|
|
510
579
|
|
|
511
580
|
this.adopting.add(id);
|
|
512
581
|
|
|
513
|
-
let plan:
|
|
582
|
+
let plan: HarnessPlan;
|
|
514
583
|
|
|
515
584
|
try {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
585
|
+
const setup = await this.setupHarness(
|
|
586
|
+
adapter,
|
|
587
|
+
provider,
|
|
588
|
+
s.id,
|
|
589
|
+
s.hostKey,
|
|
590
|
+
s.target,
|
|
591
|
+
{
|
|
592
|
+
prompt: '',
|
|
593
|
+
resume: s.agentSessionID,
|
|
594
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
595
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
596
|
+
},
|
|
597
|
+
authSetup,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
plan = setup.plan;
|
|
522
601
|
} finally {
|
|
523
602
|
this.adopting.delete(id);
|
|
524
603
|
}
|
|
@@ -544,10 +623,11 @@ export class SessionManager {
|
|
|
544
623
|
bin: plan.bin,
|
|
545
624
|
args: plan.args,
|
|
546
625
|
cwd: s.cwd,
|
|
547
|
-
env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
|
|
626
|
+
env: { ...plan.env, ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
|
|
548
627
|
withheldEnv: s.withheldEnv,
|
|
549
628
|
cols,
|
|
550
629
|
rows,
|
|
630
|
+
...this.buildBrokerSpec(s.hostKey, plan.admission),
|
|
551
631
|
onRelay: (relay) => {
|
|
552
632
|
this.onRelay(binding, relay);
|
|
553
633
|
},
|
|
@@ -567,6 +647,10 @@ export class SessionManager {
|
|
|
567
647
|
this.onEvent('state', s);
|
|
568
648
|
this.emitChange();
|
|
569
649
|
|
|
650
|
+
if (auth !== null) {
|
|
651
|
+
await this.waitForAuthStart(s, provider, pty);
|
|
652
|
+
}
|
|
653
|
+
|
|
570
654
|
for (const asleep of this.sessions) {
|
|
571
655
|
if (asleep.hostKey === s.hostKey && asleep.target === s.target && asleep.suspended) {
|
|
572
656
|
await this.tryAdoptSibling(asleep, s, cols, rows);
|
|
@@ -754,16 +838,42 @@ export class SessionManager {
|
|
|
754
838
|
// remote directory is not on the daemon's machine, so it is its own root.
|
|
755
839
|
const repoRoot = provider.remote ? cwd : resolveRepoRoot(cwd);
|
|
756
840
|
const hostKey = this.pickHostKey(id, parent, target, execution.identity);
|
|
841
|
+
const auth = this.resolveHarnessAuth(adapter, provider, target);
|
|
757
842
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
843
|
+
if (hostKey !== id) {
|
|
844
|
+
await this.requireSharedBinding(hostKey, auth?.binding ?? null);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const authSetup: HarnessAuthSetup | null =
|
|
848
|
+
auth === null
|
|
849
|
+
? null
|
|
850
|
+
: {
|
|
851
|
+
...auth,
|
|
852
|
+
mode: hostKey === id ? 'create' : 'verify',
|
|
853
|
+
targetIdentity: execution.identity,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const setup = await this.setupHarness(
|
|
857
|
+
adapter,
|
|
858
|
+
provider,
|
|
859
|
+
id,
|
|
860
|
+
hostKey,
|
|
861
|
+
target,
|
|
862
|
+
{ prompt, resume, ...overrides },
|
|
863
|
+
authSetup,
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const plan = setup.plan;
|
|
763
867
|
|
|
764
868
|
// The caller's check runs again once the host is ready, before the
|
|
765
869
|
// harness starts.
|
|
766
|
-
|
|
870
|
+
try {
|
|
871
|
+
requireInReach();
|
|
872
|
+
} catch (error) {
|
|
873
|
+
await this.tryRemoveAuthAttempt(provider, hostKey, setup.attemptID);
|
|
874
|
+
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
767
877
|
|
|
768
878
|
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
769
879
|
|
|
@@ -773,10 +883,11 @@ export class SessionManager {
|
|
|
773
883
|
bin: plan.bin,
|
|
774
884
|
args: plan.args,
|
|
775
885
|
cwd,
|
|
776
|
-
env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
|
|
886
|
+
env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
|
|
777
887
|
withheldEnv: materialized?.withheldEnv ?? [],
|
|
778
888
|
cols,
|
|
779
889
|
rows,
|
|
890
|
+
...this.buildBrokerSpec(hostKey, plan.admission),
|
|
780
891
|
onRelay: (relay) => {
|
|
781
892
|
this.onRelay(binding, relay);
|
|
782
893
|
},
|
|
@@ -827,9 +938,151 @@ export class SessionManager {
|
|
|
827
938
|
this.onEvent('added', session);
|
|
828
939
|
this.onBoot(session, cols, rows);
|
|
829
940
|
|
|
941
|
+
// A start impd refuses throws here, after the session listed, so the
|
|
942
|
+
// caller takes the failed spawn back with its binding. A shared host
|
|
943
|
+
// that nothing else runs on goes back to sleep first; a host of its
|
|
944
|
+
// own goes with the take-back.
|
|
945
|
+
if (auth !== null && hostKey === id) {
|
|
946
|
+
await pty.waitForStart?.();
|
|
947
|
+
} else if (auth !== null) {
|
|
948
|
+
await this.waitForAuthStart(session, provider, pty);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (setup.attemptID !== null) {
|
|
952
|
+
await this.requireAuthBinder().updateReady(hostKey, setup.attemptID);
|
|
953
|
+
}
|
|
954
|
+
|
|
830
955
|
return session;
|
|
831
956
|
}
|
|
832
957
|
|
|
958
|
+
// What a harness of this agent needs to take its credential from impd's
|
|
959
|
+
// broker on a provider, or null when the agent takes none. Throws the
|
|
960
|
+
// refusal for a target whose provider has no broker, so no credential
|
|
961
|
+
// ever falls back to a local start, and for a selection the current auth
|
|
962
|
+
// profiles cannot bind.
|
|
963
|
+
private resolveHarnessAuth(
|
|
964
|
+
adapter: AgentAdapter,
|
|
965
|
+
provider: ExecutionProvider,
|
|
966
|
+
target: string,
|
|
967
|
+
): HarnessAuth | null {
|
|
968
|
+
const selection = adapter.findAuthSelection?.() ?? null;
|
|
969
|
+
|
|
970
|
+
if (selection === null) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const host = provider.brokerAuth;
|
|
975
|
+
|
|
976
|
+
if (host === undefined || this.authBinder === null) {
|
|
977
|
+
throw buildBrokerTargetRefusal(adapter.id, target);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const planned = buildAuthBinding(selection.gateway, selection.profiles);
|
|
981
|
+
|
|
982
|
+
if ('problem' in planned) {
|
|
983
|
+
throw new DaemonError('auth_binding_invalid', planned.problem.message, {
|
|
984
|
+
agent: adapter.id,
|
|
985
|
+
problem: planned.problem.code,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return { host, binding: planned.binding };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// A sub-session joins its parent's host only under the binding that host
|
|
993
|
+
// holds: both without runtime auth, or both bound to the same hash.
|
|
994
|
+
private async requireSharedBinding(
|
|
995
|
+
hostKey: SessionID,
|
|
996
|
+
binding: AuthBinding | null,
|
|
997
|
+
): Promise<void> {
|
|
998
|
+
const found = await this.authBinder?.findBinding(hostKey);
|
|
999
|
+
|
|
1000
|
+
const held = found ?? null;
|
|
1001
|
+
|
|
1002
|
+
if (held === null && binding === null) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (held !== null && binding !== null && held.bindingHash === binding.hash) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
throw new DaemonError(
|
|
1011
|
+
'auth_binding_mismatch',
|
|
1012
|
+
`a sub-session joins the host of session ${hostKey} only under the runtime auth binding that host holds`,
|
|
1013
|
+
{ host: hostKey, hostBound: held !== null, bound: binding !== null },
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Takes back what a spawn attempt bound when the spawn fails before its
|
|
1018
|
+
// session lists; a take-back that cannot be confirmed throws.
|
|
1019
|
+
private async tryRemoveAuthAttempt(
|
|
1020
|
+
provider: ExecutionProvider,
|
|
1021
|
+
hostKey: SessionID,
|
|
1022
|
+
attemptID: string | null,
|
|
1023
|
+
): Promise<void> {
|
|
1024
|
+
const host = provider.brokerAuth;
|
|
1025
|
+
|
|
1026
|
+
if (attemptID === null || host === undefined || this.authBinder === null) {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
await this.authBinder.removeAttempt(host, hostKey, attemptID);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Waits for a revived harness behind the broker to start. A refused start
|
|
1034
|
+
// already ended the harness; the host goes back to sleep when no other
|
|
1035
|
+
// harness runs there, and the refusal is thrown.
|
|
1036
|
+
private async waitForAuthStart(
|
|
1037
|
+
s: Session,
|
|
1038
|
+
provider: ExecutionProvider,
|
|
1039
|
+
pty: HarnessHandle,
|
|
1040
|
+
): Promise<void> {
|
|
1041
|
+
try {
|
|
1042
|
+
await pty.waitForStart?.();
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if (this.isHostIdle(s.hostKey, s.target) && provider.capabilities.suspend) {
|
|
1045
|
+
await this.trySuspendIdleHost(provider, s.hostKey, s.target);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Whether no harness runs on a host and no launch is readying it.
|
|
1053
|
+
private isHostIdle(hostKey: SessionID, target: string): boolean {
|
|
1054
|
+
return (
|
|
1055
|
+
!this.readying.has(hostKey) &&
|
|
1056
|
+
!this.sessions.some(
|
|
1057
|
+
(other) => other.hostKey === hostKey && other.target === target && other.pty !== null,
|
|
1058
|
+
)
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private async trySuspendIdleHost(
|
|
1063
|
+
provider: ExecutionProvider,
|
|
1064
|
+
hostKey: SessionID,
|
|
1065
|
+
target: string,
|
|
1066
|
+
): Promise<void> {
|
|
1067
|
+
try {
|
|
1068
|
+
await provider.suspendHost(hostKey, () => this.isHostIdle(hostKey, target));
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
this.log(
|
|
1071
|
+
`atc could not put the host of session ${hostKey} back to sleep after a refused start (${error instanceof Error ? error.message : String(error)})`,
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const onHost of this.sessions) {
|
|
1078
|
+
if (onHost.hostKey === hostKey && this.findProvider(onHost) === provider) {
|
|
1079
|
+
this.updateSuspended(onHost);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
this.emitChange();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
833
1086
|
// The binding a harness start or attach opens its bridge under, at a
|
|
834
1087
|
// fresh epoch.
|
|
835
1088
|
private mintBridgeBinding(
|
|
@@ -848,7 +1101,10 @@ export class SessionManager {
|
|
|
848
1101
|
// Readies the host a harness is about to start on and plans the harness.
|
|
849
1102
|
// On a remote host the agent plans a guest spawn, whose files unpack into
|
|
850
1103
|
// the session's own guest folder, and the agent's sign-in check runs
|
|
851
|
-
// there first. Every refusal comes before the harness starts.
|
|
1104
|
+
// there first. Every refusal comes before the harness starts. A harness
|
|
1105
|
+
// behind the broker has its binding created or verified before the host
|
|
1106
|
+
// is readied, and a binding this call created is taken back when a later
|
|
1107
|
+
// step fails; the attempt that created it comes back with the plan.
|
|
852
1108
|
private async setupHarness(
|
|
853
1109
|
adapter: AgentAdapter,
|
|
854
1110
|
provider: ExecutionProvider,
|
|
@@ -856,35 +1112,194 @@ export class SessionManager {
|
|
|
856
1112
|
hostKey: SessionID,
|
|
857
1113
|
target: string,
|
|
858
1114
|
options: SpawnOptions,
|
|
859
|
-
|
|
1115
|
+
auth: HarnessAuthSetup | null,
|
|
1116
|
+
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
1117
|
+
this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
|
|
1118
|
+
|
|
1119
|
+
try {
|
|
1120
|
+
return await this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth);
|
|
1121
|
+
} finally {
|
|
1122
|
+
const left = (this.readying.get(hostKey) ?? 1) - 1;
|
|
1123
|
+
|
|
1124
|
+
if (left === 0) {
|
|
1125
|
+
this.readying.delete(hostKey);
|
|
1126
|
+
} else {
|
|
1127
|
+
this.readying.set(hostKey, left);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private async setupHarnessOnHost(
|
|
1133
|
+
adapter: AgentAdapter,
|
|
1134
|
+
provider: ExecutionProvider,
|
|
1135
|
+
id: SessionID,
|
|
1136
|
+
hostKey: SessionID,
|
|
1137
|
+
target: string,
|
|
1138
|
+
options: SpawnOptions,
|
|
1139
|
+
auth: HarnessAuthSetup | null,
|
|
1140
|
+
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
860
1141
|
const refusal = adapter.findSpawnRefusal?.() ?? null;
|
|
861
1142
|
|
|
862
1143
|
if (refusal !== null) {
|
|
863
1144
|
throw refusal;
|
|
864
1145
|
}
|
|
865
1146
|
|
|
1147
|
+
if (auth === null) {
|
|
1148
|
+
await this.requireUnboundHost(adapter.id, hostKey);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
866
1151
|
if (!provider.remote) {
|
|
867
|
-
await provider.prepareHost({
|
|
1152
|
+
await provider.prepareHost({
|
|
1153
|
+
host: hostKey,
|
|
1154
|
+
daemonID: this.store.daemonID,
|
|
1155
|
+
isIdle: () => this.isHostIdle(hostKey, target),
|
|
1156
|
+
});
|
|
868
1157
|
|
|
869
|
-
return
|
|
1158
|
+
return {
|
|
1159
|
+
plan: { ...adapter.planSpawn(options), env: {}, admission: null },
|
|
1160
|
+
attemptID: null,
|
|
1161
|
+
};
|
|
870
1162
|
}
|
|
871
1163
|
|
|
872
1164
|
const guest = provider.guest ?? { dir: '/tmp/atc', atc: null };
|
|
873
1165
|
const dir = `${guest.dir}/sessions/${id}`;
|
|
874
1166
|
|
|
1167
|
+
const paths: GuestPaths =
|
|
1168
|
+
auth === null
|
|
1169
|
+
? { atc: guest.atc, dir }
|
|
1170
|
+
: {
|
|
1171
|
+
atc: guest.atc,
|
|
1172
|
+
dir,
|
|
1173
|
+
auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding.placeholderEnv),
|
|
1174
|
+
};
|
|
1175
|
+
|
|
875
1176
|
const plan =
|
|
876
1177
|
adapter.planGuestSpawn === undefined
|
|
877
1178
|
? { ...adapter.planSpawn(options), files: {} }
|
|
878
|
-
: adapter.planGuestSpawn(options,
|
|
1179
|
+
: adapter.planGuestSpawn(options, paths);
|
|
879
1180
|
|
|
880
1181
|
if (plan === null) {
|
|
881
|
-
throw
|
|
1182
|
+
throw auth === null
|
|
1183
|
+
? buildGuestRefusal(provider.kind, adapter.id, target, guest.atc === null)
|
|
1184
|
+
: new DaemonError(
|
|
1185
|
+
'auth_target_unsupported',
|
|
1186
|
+
`agent '${adapter.id}' plans no guest settings to take its credential from impd's broker on target '${target}'`,
|
|
1187
|
+
{ agent: adapter.id, target, problem: 'no_guest_plan' },
|
|
1188
|
+
);
|
|
882
1189
|
}
|
|
883
1190
|
|
|
1191
|
+
const env = plan.env ?? {};
|
|
1192
|
+
|
|
1193
|
+
requireGuestEnv(adapter.id, target, env, auth !== null);
|
|
1194
|
+
|
|
1195
|
+
const attemptID = await this.applyHarnessAuth(hostKey, target, auth);
|
|
1196
|
+
|
|
1197
|
+
try {
|
|
1198
|
+
await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
await this.tryRemoveAuthAttempt(provider, hostKey, attemptID);
|
|
1201
|
+
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const admission =
|
|
1206
|
+
auth === null || paths.auth === undefined
|
|
1207
|
+
? null
|
|
1208
|
+
: { revision: paths.auth.revision, hash: auth.binding.hash, attemptID };
|
|
1209
|
+
|
|
1210
|
+
return { plan: { bin: plan.bin, args: plan.args, env, admission }, attemptID };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// The part of a harness spec that keeps a launch behind the broker: the
|
|
1214
|
+
// requirement impd checks, and the admission each request needs from
|
|
1215
|
+
// the host's binding when it goes out.
|
|
1216
|
+
private buildBrokerSpec(
|
|
1217
|
+
hostKey: SessionID,
|
|
1218
|
+
admission: HarnessPlan['admission'],
|
|
1219
|
+
): Pick<HarnessSpec, 'requireBroker' | 'admit'> {
|
|
1220
|
+
if (admission === null) {
|
|
1221
|
+
return {};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const binder = this.requireAuthBinder();
|
|
1225
|
+
|
|
1226
|
+
return {
|
|
1227
|
+
requireBroker: true,
|
|
1228
|
+
admit: (kind, send) => binder.withLaunchAdmission(hostKey, admission, kind, send),
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// A launch without runtime auth never starts on a host that holds a
|
|
1233
|
+
// binding, whatever its state: the imp may hold grants atc could not
|
|
1234
|
+
// confirm gone, and only a rebind or a forget of the host clears it.
|
|
1235
|
+
private async requireUnboundHost(agent: string, hostKey: SessionID): Promise<void> {
|
|
1236
|
+
const held = this.authBinder === null ? null : await this.authBinder.findBinding(hostKey);
|
|
1237
|
+
|
|
1238
|
+
if (held !== null) {
|
|
1239
|
+
throw new DaemonError(
|
|
1240
|
+
'auth_rebind_required',
|
|
1241
|
+
`agent '${agent}' takes no credential from impd's broker, but its host holds a runtime auth binding; rebind the session or forget it`,
|
|
1242
|
+
{ agent, state: held.state },
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// The binding revision and placeholders a guest plan behind the broker
|
|
1248
|
+
// launches under: the next host's first revision, or the revision the
|
|
1249
|
+
// shared host holds.
|
|
1250
|
+
private async planGuestAuth(
|
|
1251
|
+
hostKey: SessionID,
|
|
1252
|
+
mode: HarnessAuthSetup['mode'],
|
|
1253
|
+
placeholderEnv: Readonly<Record<string, string>>,
|
|
1254
|
+
): Promise<NonNullable<GuestPaths['auth']>> {
|
|
1255
|
+
const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
|
|
1256
|
+
|
|
1257
|
+
return { revision: held?.revision ?? 1, env: placeholderEnv };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Creates the binding of a host a spawn provisions, or verifies the one
|
|
1261
|
+
// a revive or a sub-session launches under, and returns the attempt
|
|
1262
|
+
// that created it, null for a verify or no auth.
|
|
1263
|
+
private async applyHarnessAuth(
|
|
1264
|
+
hostKey: SessionID,
|
|
1265
|
+
target: string,
|
|
1266
|
+
auth: HarnessAuthSetup | null,
|
|
1267
|
+
): Promise<string | null> {
|
|
1268
|
+
if (auth === null) {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const binder = this.requireAuthBinder();
|
|
1273
|
+
|
|
1274
|
+
if (auth.mode === 'verify') {
|
|
1275
|
+
await binder.verifyBinding(auth.host, hostKey, auth.binding);
|
|
1276
|
+
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return binder.createBinding(auth.host, {
|
|
1281
|
+
hostKey,
|
|
1282
|
+
target,
|
|
1283
|
+
targetIdentity: auth.targetIdentity,
|
|
1284
|
+
binding: auth.binding,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Readies a remote host and the files a guest plan reads there, after
|
|
1289
|
+
// the agent's sign-in check.
|
|
1290
|
+
private async setupGuest(
|
|
1291
|
+
adapter: AgentAdapter,
|
|
1292
|
+
provider: ExecutionProvider,
|
|
1293
|
+
hostKey: SessionID,
|
|
1294
|
+
target: string,
|
|
1295
|
+
dir: string,
|
|
1296
|
+
planned: Readonly<Record<string, string>>,
|
|
1297
|
+
): Promise<void> {
|
|
884
1298
|
await provider.prepareHost({
|
|
885
1299
|
host: hostKey,
|
|
886
1300
|
daemonID: this.store.daemonID,
|
|
887
1301
|
installATC: adapter.planGuestSpawn !== undefined,
|
|
1302
|
+
isIdle: () => this.isHostIdle(hostKey, target),
|
|
888
1303
|
});
|
|
889
1304
|
|
|
890
1305
|
const check = adapter.planAuthCheck?.();
|
|
@@ -901,13 +1316,11 @@ export class SessionManager {
|
|
|
901
1316
|
}
|
|
902
1317
|
}
|
|
903
1318
|
|
|
904
|
-
const files = Object.entries(
|
|
1319
|
+
const files = Object.entries(planned).map(([path, content]) => ({ path, content }));
|
|
905
1320
|
|
|
906
1321
|
if (files.length > 0) {
|
|
907
1322
|
await provider.transferArchive(buildTarArchive(files), dir, hostKey);
|
|
908
1323
|
}
|
|
909
|
-
|
|
910
|
-
return plan;
|
|
911
1324
|
}
|
|
912
1325
|
|
|
913
1326
|
// A sub-session runs on its parent's host when its resolved target, name
|
|
@@ -970,11 +1383,25 @@ export class SessionManager {
|
|
|
970
1383
|
this.emitChange();
|
|
971
1384
|
|
|
972
1385
|
await this.writeFleet();
|
|
1386
|
+
await this.removeSpawnBinding(s);
|
|
973
1387
|
} finally {
|
|
974
1388
|
this.rollingBack.delete(id);
|
|
975
1389
|
}
|
|
976
1390
|
}
|
|
977
1391
|
|
|
1392
|
+
// Takes back the binding a failed spawn provisioned for its own host,
|
|
1393
|
+
// while it is still the spawn's attempt.
|
|
1394
|
+
private async removeSpawnBinding(s: Session): Promise<void> {
|
|
1395
|
+
const provider = this.findProvider(s);
|
|
1396
|
+
const held = s.hostKey === s.id ? await this.authBinder?.findBinding(s.id) : null;
|
|
1397
|
+
|
|
1398
|
+
if (provider === null || held === null || held === undefined || held.state !== 'provisioning') {
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
await this.tryRemoveAuthAttempt(provider, s.id, held.attemptID);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
978
1405
|
// Kills a failed spawn's harness and waits for its exit. A kill that
|
|
979
1406
|
// throws or an exit it cannot confirm leaves the harness recorded as
|
|
980
1407
|
// possibly running, which blocks revives of the session.
|
|
@@ -1340,7 +1767,14 @@ export class SessionManager {
|
|
|
1340
1767
|
}
|
|
1341
1768
|
|
|
1342
1769
|
if (destroys) {
|
|
1343
|
-
|
|
1770
|
+
const bound =
|
|
1771
|
+
this.authBinder === null
|
|
1772
|
+
? false
|
|
1773
|
+
: await this.authBinder.forgetBinding(provider.brokerAuth ?? null, s.hostKey);
|
|
1774
|
+
|
|
1775
|
+
if (!bound) {
|
|
1776
|
+
await provider.destroyHost(s.hostKey);
|
|
1777
|
+
}
|
|
1344
1778
|
|
|
1345
1779
|
for (const onHost of this.sessions) {
|
|
1346
1780
|
if (onHost.hostKey === s.hostKey && onHost.target === s.target && onHost.id !== s.id) {
|
|
@@ -1366,6 +1800,76 @@ export class SessionManager {
|
|
|
1366
1800
|
return destroys;
|
|
1367
1801
|
}
|
|
1368
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* Withdraws every grant of the runtime auth binding on a session's host.
|
|
1805
|
+
* Launches on the host stay blocked until a rebind, and a harness that
|
|
1806
|
+
* runs keeps running while impd fails its later requests. Returns false
|
|
1807
|
+
* for no such session.
|
|
1808
|
+
*/
|
|
1809
|
+
async revokeAuth(id: SessionID): Promise<boolean> {
|
|
1810
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
1811
|
+
|
|
1812
|
+
if (s === undefined) {
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
await this.requireAuthBinder().revokeBinding(
|
|
1817
|
+
this.findProvider(s)?.brokerAuth ?? null,
|
|
1818
|
+
s.hostKey,
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1821
|
+
return true;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Binds a session's host to its agent's current auth selection at the
|
|
1826
|
+
* next revision, and returns that revision, or null for no such session.
|
|
1827
|
+
*/
|
|
1828
|
+
async updateAuth(id: SessionID): Promise<number | null> {
|
|
1829
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
1830
|
+
|
|
1831
|
+
if (s === undefined) {
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const adapter = this.findAdapter(s.agent);
|
|
1836
|
+
|
|
1837
|
+
if (adapter === null) {
|
|
1838
|
+
throw new DaemonError('unsupported_operation', `no adapter for agent '${s.agent}'`, {
|
|
1839
|
+
agent: s.agent,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const provider = this.requireExecution(s, 'spawn').provider;
|
|
1844
|
+
const auth = this.resolveHarnessAuth(adapter, provider, s.target);
|
|
1845
|
+
|
|
1846
|
+
if (auth === null) {
|
|
1847
|
+
throw new DaemonError(
|
|
1848
|
+
'unsupported_operation',
|
|
1849
|
+
`agent '${s.agent}' takes no credential from impd's broker`,
|
|
1850
|
+
{ agent: s.agent, problem: 'no_auth_selection' },
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const revision = await this.requireAuthBinder().updateBinding(
|
|
1855
|
+
auth.host,
|
|
1856
|
+
s.hostKey,
|
|
1857
|
+
auth.binding,
|
|
1858
|
+
);
|
|
1859
|
+
|
|
1860
|
+
return revision;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private requireAuthBinder(): RuntimeAuthBinder {
|
|
1864
|
+
if (this.authBinder === null) {
|
|
1865
|
+
throw new DaemonError('unsupported_operation', 'this daemon binds no runtime auth', {
|
|
1866
|
+
problem: 'no_auth_binder',
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
return this.authBinder;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1369
1873
|
// Removes a forgotten session and its dead sub-sessions, except one whose
|
|
1370
1874
|
// own target can destroy its host: forgetting that one destroys the host,
|
|
1371
1875
|
// which takes its own confirmed forget. Only a sub-session among the given
|
|
@@ -1641,6 +2145,16 @@ function buildLifecycle(s: Session): SessionLifecycle {
|
|
|
1641
2145
|
});
|
|
1642
2146
|
}
|
|
1643
2147
|
|
|
2148
|
+
// An agent that takes its credential from impd's broker on a target that
|
|
2149
|
+
// reaches none.
|
|
2150
|
+
function buildBrokerTargetRefusal(agent: string, target: string): DaemonError {
|
|
2151
|
+
return new DaemonError(
|
|
2152
|
+
'auth_target_unsupported',
|
|
2153
|
+
`agent '${agent}' takes its credential from impd's broker, which target '${target}' does not reach`,
|
|
2154
|
+
{ agent, target },
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1644
2158
|
// An agent that plans no remote spawn: for want of an atc inside the host
|
|
1645
2159
|
// when the host has none, or because the agent never runs remotely.
|
|
1646
2160
|
function buildGuestRefusal(
|
|
@@ -1662,6 +2176,32 @@ function buildGuestRefusal(
|
|
|
1662
2176
|
);
|
|
1663
2177
|
}
|
|
1664
2178
|
|
|
2179
|
+
// A guest plan's variables start the harness as they are, so none may be
|
|
2180
|
+
// one atc sets for its own reporting, and behind the broker none may be a
|
|
2181
|
+
// proxy or CA variable that would route around the broker.
|
|
2182
|
+
function requireGuestEnv(
|
|
2183
|
+
agent: string,
|
|
2184
|
+
target: string,
|
|
2185
|
+
env: Readonly<Record<string, string>>,
|
|
2186
|
+
behindBroker: boolean,
|
|
2187
|
+
): void {
|
|
2188
|
+
const own = Object.keys(env).find((key) => key.startsWith('ATC_'));
|
|
2189
|
+
|
|
2190
|
+
if (own !== undefined) {
|
|
2191
|
+
throw new Error(`agent '${agent}' plans ${own}, which atc sets for the harness itself`);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
const broker = behindBroker ? Object.keys(env).find((key) => isBrokerVariable(key)) : undefined;
|
|
2195
|
+
|
|
2196
|
+
if (broker !== undefined) {
|
|
2197
|
+
throw new DaemonError(
|
|
2198
|
+
'auth_target_unsupported',
|
|
2199
|
+
`agent '${agent}' plans ${broker} on target '${target}', which would route around impd's broker`,
|
|
2200
|
+
{ agent, target, problem: 'guest_env_conflict', variable: broker },
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1665
2205
|
// Why a harness stopped, as a session's last message.
|
|
1666
2206
|
function pickExitMessage(exit: Readonly<{ reason?: string; detail?: string }>): string {
|
|
1667
2207
|
if (exit.reason === 'suspended') {
|