@zgeoff/atc 2.24.0 → 2.25.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 +21 -3
- 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 +52 -2
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +58 -1
- package/src/daemon/execution-provider.ts +50 -2
- 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/restore-fleet.ts +15 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- 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 +16 -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/package.json
CHANGED
|
@@ -3,6 +3,8 @@ import type { DaemonError } from '../protocol/daemon-error';
|
|
|
3
3
|
import type { HookEvent } from '../protocol/hook-event';
|
|
4
4
|
import type { AgentID } from '../shared/agent-id';
|
|
5
5
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
|
+
import type { AuthProfile } from '../shared/collect-auth-profiles';
|
|
7
|
+
import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
|
|
6
8
|
import type { SessionID } from '../shared/session-id';
|
|
7
9
|
|
|
8
10
|
export interface SpawnOptions {
|
|
@@ -35,19 +37,36 @@ export interface SpawnPlan {
|
|
|
35
37
|
/**
|
|
36
38
|
* Where a session on a remote host finds atc: the atc binary inside the
|
|
37
39
|
* host, null when the host has none, and the folder the session's own
|
|
38
|
-
* files unpack into.
|
|
40
|
+
* files unpack into. `auth` is given when the harness takes its credential
|
|
41
|
+
* from impd's broker: the revision of the host's runtime auth binding it
|
|
42
|
+
* launches under, which keys any settings the agent writes for it, and the
|
|
43
|
+
* placeholder variables the harness holds in place of a credential.
|
|
39
44
|
*/
|
|
40
45
|
export interface GuestPaths {
|
|
41
46
|
readonly atc: string | null;
|
|
42
47
|
readonly dir: string;
|
|
48
|
+
readonly auth?: { readonly revision: number; readonly env: Readonly<Record<string, string>> };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The credential an agent takes from impd's broker instead of holding it:
|
|
53
|
+
* its gateway's endpoint and auth selection, and the auth profiles that
|
|
54
|
+
* selection resolves against.
|
|
55
|
+
*/
|
|
56
|
+
export interface AuthSelection {
|
|
57
|
+
readonly gateway: Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
|
|
58
|
+
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/**
|
|
46
62
|
* A spawn on a remote host, with the files the harness reads there, keyed
|
|
47
|
-
* by their path inside the session's guest folder
|
|
63
|
+
* by their path inside the session's guest folder, and the variables the
|
|
64
|
+
* harness process starts with, which no variable the harness inherits
|
|
65
|
+
* overrides.
|
|
48
66
|
*/
|
|
49
67
|
export interface GuestSpawnPlan extends SpawnPlan {
|
|
50
68
|
readonly files: Readonly<Record<string, string>>;
|
|
69
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
export interface TranscriptToolUse {
|
|
@@ -204,8 +223,14 @@ export interface AgentAdapter {
|
|
|
204
223
|
// Plans a spawn on a remote host; null when this agent cannot run there,
|
|
205
224
|
// such as one whose instrumentation needs atc on a host without it.
|
|
206
225
|
// Absent: the agent runs there as a local spawn plans it, with no files.
|
|
226
|
+
// A plan for a guest whose `auth` is given that cannot launch behind the
|
|
227
|
+
// broker is null.
|
|
207
228
|
readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
|
|
208
229
|
|
|
230
|
+
// The credential this agent takes from impd's broker, or null when it
|
|
231
|
+
// takes none. Absent: it takes none.
|
|
232
|
+
readonly findAuthSelection?: () => AuthSelection | null;
|
|
233
|
+
|
|
209
234
|
// The refusal every start of this agent's harness gets, on any target,
|
|
210
235
|
// or null when it may start. Absent: no start is refused.
|
|
211
236
|
readonly findSpawnRefusal?: () => DaemonError | null;
|
|
@@ -9,6 +9,7 @@ import { toShellArg } from '../shared/to-shell-arg';
|
|
|
9
9
|
import type {
|
|
10
10
|
AgentAdapter,
|
|
11
11
|
AgentProfile,
|
|
12
|
+
AuthSelection,
|
|
12
13
|
HeadlessRunner,
|
|
13
14
|
NameUpdate,
|
|
14
15
|
ResumeCheck,
|
|
@@ -59,6 +60,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
59
60
|
|
|
60
61
|
private readonly gateway: GatewayConfig;
|
|
61
62
|
|
|
63
|
+
private readonly config: Config;
|
|
64
|
+
|
|
62
65
|
private readonly claude: ClaudeAdapter;
|
|
63
66
|
|
|
64
67
|
// Written on first spawn so constructing the adapter touches no state.
|
|
@@ -77,6 +80,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
77
80
|
) {
|
|
78
81
|
this.bridgeTarget = bridgeTarget;
|
|
79
82
|
this.gateway = gateway;
|
|
83
|
+
this.config = config;
|
|
80
84
|
this.id = gateway.id;
|
|
81
85
|
|
|
82
86
|
const models = pickModels(gateway.env);
|
|
@@ -102,8 +106,9 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
102
106
|
});
|
|
103
107
|
}
|
|
104
108
|
|
|
105
|
-
// A gateway whose credential comes through impd's broker
|
|
106
|
-
//
|
|
109
|
+
// A gateway whose credential comes through impd's broker starts only in a
|
|
110
|
+
// guest that reaches the broker, with guest settings the adapter plans
|
|
111
|
+
// for it, and it plans none: started without them, the CLI would send
|
|
107
112
|
// whatever credential it holds to the gateway's host.
|
|
108
113
|
findSpawnRefusal(): DaemonError | null {
|
|
109
114
|
if (this.gateway.auth === undefined) {
|
|
@@ -112,11 +117,24 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
112
117
|
|
|
113
118
|
return new DaemonError(
|
|
114
119
|
'auth_target_unsupported',
|
|
115
|
-
`gateway '${this.id}' takes its credential from impd's broker, and
|
|
120
|
+
`gateway '${this.id}' takes its credential from impd's broker, and atc plans no guest settings for it on any target`,
|
|
116
121
|
{ agent: this.id },
|
|
117
122
|
);
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
findAuthSelection(): AuthSelection | null {
|
|
126
|
+
const auth = this.gateway.auth;
|
|
127
|
+
|
|
128
|
+
if (auth === undefined) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
|
|
134
|
+
profiles: this.config.authProfiles,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
121
139
|
return {
|
|
122
140
|
bin: this.gateway.bin,
|
|
@@ -13,6 +13,10 @@ export interface TargetPick {
|
|
|
13
13
|
// Whether a session there runs on the daemon's own machine, where a
|
|
14
14
|
// local directory runs in place.
|
|
15
15
|
readonly inPlace: boolean;
|
|
16
|
+
|
|
17
|
+
// Whether the target reaches impd's credential broker; false from a
|
|
18
|
+
// daemon that does not say.
|
|
19
|
+
readonly brokerAuth: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -47,6 +51,7 @@ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): T
|
|
|
47
51
|
takesWorkspace:
|
|
48
52
|
available && capabilities['transfer'] === true && capabilities['run'] === true,
|
|
49
53
|
inPlace: entry['provider'] === 'local-pty',
|
|
54
|
+
brokerAuth: entry['brokerAuth'] === true,
|
|
50
55
|
},
|
|
51
56
|
];
|
|
52
57
|
});
|
|
@@ -1616,7 +1616,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1616
1616
|
return;
|
|
1617
1617
|
}
|
|
1618
1618
|
|
|
1619
|
-
|
|
1619
|
+
// An agent that takes its credential from impd's broker runs only on a
|
|
1620
|
+
// target that reaches the broker.
|
|
1621
|
+
const brokered = isBrokerAgent(listed['agents'], this.agent);
|
|
1622
|
+
const picks = collectTargetPicks(listed).filter((t) => !brokered || t.brokerAuth);
|
|
1620
1623
|
|
|
1621
1624
|
// An adopt resumes a session from its history on this host, which a
|
|
1622
1625
|
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
@@ -1870,6 +1873,17 @@ function buildProbedRepo(
|
|
|
1870
1873
|
};
|
|
1871
1874
|
}
|
|
1872
1875
|
|
|
1876
|
+
// Whether the daemon lists an agent as taking its credential from impd's
|
|
1877
|
+
// broker; a daemon that does not say lists none.
|
|
1878
|
+
function isBrokerAgent(raw: unknown, agent: AgentID): boolean {
|
|
1879
|
+
return (
|
|
1880
|
+
Array.isArray(raw) &&
|
|
1881
|
+
raw.some(
|
|
1882
|
+
(entry: unknown) => isRecord(entry) && entry['id'] === agent && entry['brokerAuth'] === true,
|
|
1883
|
+
)
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1873
1887
|
function parseAnnouncedSources(raw: unknown): AnnouncedSource[] {
|
|
1874
1888
|
if (!Array.isArray(raw)) {
|
|
1875
1889
|
return [];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ImpPort, ImpView } from './imp-port';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a provider whose hosts can reach impd's credential broker offers
|
|
5
|
+
* runtime auth: the literal start of every imp name it builds, the imp a
|
|
6
|
+
* host key runs on, impd's identity, secret and grant calls, and creating
|
|
7
|
+
* and destroying a host's imp the way the provider itself does. A
|
|
8
|
+
* destroy that finds no imp counts as done.
|
|
9
|
+
*/
|
|
10
|
+
export interface BrokerAuthHost {
|
|
11
|
+
readonly impPrefix: string;
|
|
12
|
+
readonly port: Pick<
|
|
13
|
+
ImpPort,
|
|
14
|
+
| 'readFeatures'
|
|
15
|
+
| 'readIdentity'
|
|
16
|
+
| 'readSecrets'
|
|
17
|
+
| 'readGrants'
|
|
18
|
+
| 'createGrant'
|
|
19
|
+
| 'removeGrant'
|
|
20
|
+
| 'readImp'
|
|
21
|
+
>;
|
|
22
|
+
readonly getImpName: (hostKey: string) => string;
|
|
23
|
+
readonly createImp: (hostKey: string) => Promise<ImpView>;
|
|
24
|
+
readonly destroyImp: (hostKey: string) => Promise<void>;
|
|
25
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Why impd's credential broker may not be used for a session, or why atc
|
|
3
3
|
* may not clean up after one:
|
|
4
4
|
*
|
|
5
|
-
* - `auth_impd_too_old`: impd lacks grantable tokens
|
|
5
|
+
* - `auth_impd_too_old`: impd lacks grantable tokens, secret rebinds or
|
|
6
|
+
* exec requirements.
|
|
6
7
|
* - `auth_token_scope`: the token's scope is below `manage`.
|
|
7
8
|
* - `auth_token_too_broad`: the token can reach imps outside atc's
|
|
8
9
|
* namespace, through no imp patterns or a pattern whose literal text
|
|
@@ -2,7 +2,8 @@ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
|
2
2
|
|
|
3
3
|
interface AgentCapabilities {
|
|
4
4
|
// Only an installed agent whose starts are not all refused can start a
|
|
5
|
-
// session
|
|
5
|
+
// session, and one that takes its credential from impd's broker only
|
|
6
|
+
// where some target reaches the broker.
|
|
6
7
|
readonly spawn: boolean;
|
|
7
8
|
readonly readTranscript: boolean;
|
|
8
9
|
|
|
@@ -20,6 +21,10 @@ export interface AgentEntry {
|
|
|
20
21
|
|
|
21
22
|
// Whether the agent's binary resolves, on PATH or at its configured path.
|
|
22
23
|
readonly installed: boolean;
|
|
24
|
+
|
|
25
|
+
// Whether the agent takes its credential from impd's broker, so it runs
|
|
26
|
+
// only on a target whose entry has `brokerAuth`.
|
|
27
|
+
readonly brokerAuth: boolean;
|
|
23
28
|
readonly capabilities: AgentCapabilities;
|
|
24
29
|
readonly models: Readonly<Record<string, string>> | null;
|
|
25
30
|
readonly spawnOptions: SpawnOptionEntries;
|
|
@@ -43,22 +48,30 @@ interface SpawnOptionEntries {
|
|
|
43
48
|
* so no environment value, credential, helper command, or base URL reaches
|
|
44
49
|
* it. Every session runs in a PTY, so each agent can be attached, read as a
|
|
45
50
|
* screen, and typed into. A stand-in adapter without a profile is listed
|
|
46
|
-
* under its id as both label and kind.
|
|
51
|
+
* under its id as both label and kind. hasBrokerTarget holds whether any
|
|
52
|
+
* target reaches impd's credential broker.
|
|
47
53
|
*/
|
|
48
54
|
export function buildAgentList(
|
|
49
55
|
adapters: readonly AgentAdapter[],
|
|
50
56
|
isInstalled: (bin: string) => boolean,
|
|
57
|
+
hasBrokerTarget: boolean,
|
|
51
58
|
): AgentEntry[] {
|
|
52
59
|
return adapters.map((adapter) => {
|
|
53
60
|
const profile = adapter.profile;
|
|
54
61
|
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
55
|
-
const
|
|
62
|
+
const brokerAuth = (adapter.findAuthSelection?.() ?? null) !== null;
|
|
63
|
+
|
|
64
|
+
const spawnable =
|
|
65
|
+
installed &&
|
|
66
|
+
(adapter.findSpawnRefusal?.() ?? null) === null &&
|
|
67
|
+
(!brokerAuth || hasBrokerTarget);
|
|
56
68
|
|
|
57
69
|
return {
|
|
58
70
|
id: adapter.id,
|
|
59
71
|
label: profile?.label ?? adapter.id,
|
|
60
72
|
kind: profile?.kind ?? adapter.id,
|
|
61
73
|
installed,
|
|
74
|
+
brokerAuth,
|
|
62
75
|
capabilities: {
|
|
63
76
|
spawn: spawnable,
|
|
64
77
|
readTranscript: adapter.parseTranscriptLine !== undefined,
|
|
@@ -139,6 +139,8 @@ export function buildScopedContext(
|
|
|
139
139
|
// out or taken, so its host is never touched.
|
|
140
140
|
forgetSession: (id, confirmToken) =>
|
|
141
141
|
canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
|
|
142
|
+
revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
|
|
143
|
+
updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
|
|
142
144
|
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
143
145
|
ackSession: (id) => canSee(id) && ctx.ackSession(id),
|
|
144
146
|
buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
|
|
@@ -14,6 +14,10 @@ export interface TargetEntry {
|
|
|
14
14
|
readonly available: boolean;
|
|
15
15
|
readonly default: boolean;
|
|
16
16
|
readonly capabilities: ExecutionCapabilities;
|
|
17
|
+
|
|
18
|
+
// Whether the target's provider reaches impd's credential broker, so an
|
|
19
|
+
// agent that takes its credential from the broker can run there.
|
|
20
|
+
readonly brokerAuth: boolean;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
// What a target without a provider can do: nothing.
|
|
@@ -46,5 +50,6 @@ export function buildTargetList(
|
|
|
46
50
|
available: target.provider !== null,
|
|
47
51
|
default: target.id === defaultTarget,
|
|
48
52
|
capabilities: target.provider?.capabilities ?? NO_CAPABILITIES,
|
|
53
|
+
brokerAuth: target.provider?.brokerAuth !== undefined,
|
|
49
54
|
}));
|
|
50
55
|
}
|
|
@@ -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.
|
|
@@ -648,6 +654,48 @@ export class DaemonConnection {
|
|
|
648
654
|
|
|
649
655
|
return;
|
|
650
656
|
}
|
|
657
|
+
case 'session.auth.revoke': {
|
|
658
|
+
const parsed = parseRequestParams('session.auth.revoke', req.p);
|
|
659
|
+
|
|
660
|
+
if (!parsed.ok) {
|
|
661
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
662
|
+
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const id = parsed.data.session;
|
|
667
|
+
|
|
668
|
+
const revoked = await ctx.revokeSessionAuth(id);
|
|
669
|
+
|
|
670
|
+
if (revoked) {
|
|
671
|
+
this.sendOk(req.id, { revoked: true });
|
|
672
|
+
} else {
|
|
673
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
case 'session.auth.rebind': {
|
|
679
|
+
const parsed = parseRequestParams('session.auth.rebind', req.p);
|
|
680
|
+
|
|
681
|
+
if (!parsed.ok) {
|
|
682
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
683
|
+
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const id = parsed.data.session;
|
|
688
|
+
|
|
689
|
+
const revision = await ctx.updateSessionAuth(id);
|
|
690
|
+
|
|
691
|
+
if (revision === null) {
|
|
692
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
693
|
+
} else {
|
|
694
|
+
this.sendOk(req.id, { revision });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
651
699
|
case 'session.resumeCommand': {
|
|
652
700
|
const parsed = parseRequestParams('session.resumeCommand', req.p);
|
|
653
701
|
|
|
@@ -901,6 +949,8 @@ export class DaemonConnection {
|
|
|
901
949
|
throw refusal;
|
|
902
950
|
}
|
|
903
951
|
|
|
952
|
+
ctx.requireAgentTarget(agent, target);
|
|
953
|
+
|
|
904
954
|
const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
|
|
905
955
|
|
|
906
956
|
if (!overrides.ok) {
|
|
@@ -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
|
@@ -77,6 +77,7 @@ import { PermissionRegistry } from './permission-registry';
|
|
|
77
77
|
import { requireGitTransports } from './require-git-transports';
|
|
78
78
|
import { restoreFleet } from './restore-fleet';
|
|
79
79
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
80
|
+
import { RuntimeAuthBinder } from './runtime-auth-binder';
|
|
80
81
|
import { ScreenModel } from './screen-model';
|
|
81
82
|
import { SessionRuntime } from './session-runtime';
|
|
82
83
|
import { SessionManager } from './sessions';
|
|
@@ -278,6 +279,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
278
279
|
// that stopped mid-effect is settled, so no retry can race either.
|
|
279
280
|
await store.reconcileMaterializations(Date.now());
|
|
280
281
|
await store.reconcileIdempotencyKeys(Date.now());
|
|
282
|
+
await store.reconcileAuthBindings(Date.now());
|
|
281
283
|
|
|
282
284
|
await tryRemoveExpiredIdempotencyKeys(store);
|
|
283
285
|
|
|
@@ -317,6 +319,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
317
319
|
mgr.log = opts.log;
|
|
318
320
|
}
|
|
319
321
|
|
|
322
|
+
const authBinder = new RuntimeAuthBinder(store);
|
|
323
|
+
|
|
324
|
+
mgr.authBinder = authBinder;
|
|
325
|
+
|
|
326
|
+
// Settled in the background, since it reaches impd: each binding a
|
|
327
|
+
// stopped daemon left mid-change stays blocked until it is settled.
|
|
328
|
+
void tryReconcileAuthBindings(authBinder, store, targetsByID, mgr.log);
|
|
329
|
+
|
|
320
330
|
const clients = new Set<DaemonConnection>();
|
|
321
331
|
|
|
322
332
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -1272,7 +1282,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1272
1282
|
arch: process.arch,
|
|
1273
1283
|
build: opts.build,
|
|
1274
1284
|
},
|
|
1275
|
-
agents: buildAgentList(
|
|
1285
|
+
agents: buildAgentList(
|
|
1286
|
+
mgr.collectAdapters(),
|
|
1287
|
+
(bin) => Bun.which(bin) !== null,
|
|
1288
|
+
targets.some((target) => target.provider?.brokerAuth !== undefined),
|
|
1289
|
+
),
|
|
1276
1290
|
targets: buildTargetList(targets, defaultTarget),
|
|
1277
1291
|
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
1278
1292
|
configRevision,
|
|
@@ -1324,6 +1338,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1324
1338
|
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1325
1339
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1326
1340
|
},
|
|
1341
|
+
requireAgentTarget: (agent, target) => {
|
|
1342
|
+
mgr.requireAgentTarget(agent, target);
|
|
1343
|
+
},
|
|
1327
1344
|
findSource: (id) => sources.find((source) => source.id === id) ?? null,
|
|
1328
1345
|
collectAlternateGitURLs: (url) => [
|
|
1329
1346
|
...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
|
|
@@ -1446,6 +1463,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1446
1463
|
|
|
1447
1464
|
return { forgotten: true, destroyed };
|
|
1448
1465
|
},
|
|
1466
|
+
revokeSessionAuth: (id) => mgr.revokeAuth(id),
|
|
1467
|
+
updateSessionAuth: (id) => mgr.updateAuth(id),
|
|
1449
1468
|
ejectSession: (id, prompt) => {
|
|
1450
1469
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1451
1470
|
|
|
@@ -1494,6 +1513,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1494
1513
|
return 'no_transcript';
|
|
1495
1514
|
}
|
|
1496
1515
|
|
|
1516
|
+
const listed = mgr.sessions.find((s) => s.id === id);
|
|
1517
|
+
|
|
1518
|
+
// Refused before the headless run stops, so a refusal leaves it as
|
|
1519
|
+
// it was.
|
|
1520
|
+
if (listed !== undefined) {
|
|
1521
|
+
mgr.requireAgentTarget(listed.agent, listed.target);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1497
1524
|
const runtime = runtimes.get(id);
|
|
1498
1525
|
|
|
1499
1526
|
runtime?.stopHeadlessRun();
|
|
@@ -2197,6 +2224,36 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
|
|
|
2197
2224
|
return true;
|
|
2198
2225
|
}
|
|
2199
2226
|
|
|
2227
|
+
// Settles the runtime auth bindings a stopped daemon left mid-change, each
|
|
2228
|
+
// through its target's broker; a host with a fleet entry is listed, and a
|
|
2229
|
+
// failure is logged and leaves its binding blocked.
|
|
2230
|
+
async function tryReconcileAuthBindings(
|
|
2231
|
+
binder: RuntimeAuthBinder,
|
|
2232
|
+
store: StateStore,
|
|
2233
|
+
targets: ReadonlyMap<string, ExecutionTarget>,
|
|
2234
|
+
log: (line: string) => void,
|
|
2235
|
+
): Promise<boolean> {
|
|
2236
|
+
try {
|
|
2237
|
+
const fleet = await store.loadFleet();
|
|
2238
|
+
|
|
2239
|
+
const listed = new Set(fleet.map((entry) => entry.hostKey ?? entry.sessionID));
|
|
2240
|
+
|
|
2241
|
+
await binder.reconcileBindings(
|
|
2242
|
+
(target) => targets.get(target)?.provider?.brokerAuth ?? null,
|
|
2243
|
+
listed,
|
|
2244
|
+
log,
|
|
2245
|
+
);
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
log(
|
|
2248
|
+
`atc could not settle runtime auth bindings (${error instanceof Error ? error.message : String(error)})`,
|
|
2249
|
+
);
|
|
2250
|
+
|
|
2251
|
+
return false;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
return true;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2200
2257
|
interface ConfirmToken {
|
|
2201
2258
|
readonly session: SessionID;
|
|
2202
2259
|
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.
|