@zgeoff/atc 2.23.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 +2 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +21 -3
- package/src/cli.ts +20 -8
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/clients.ts +19 -12
- 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/gateway.ts +183 -0
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/parse-gateway-state-dir.ts +94 -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/run-gateway.ts +93 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.0",
|
|
4
4
|
"description": "Terminal control tower for coding-agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
|
|
31
31
|
"test": "bash scripts/with-test-home.sh bun test",
|
|
32
32
|
"test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 test/daemon-e2e.test.ts",
|
|
33
|
+
"test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/gateway.test.ts",
|
|
33
34
|
"test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
|
|
34
35
|
"test:isolation": "bash scripts/check-test-isolation.sh",
|
|
35
36
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -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,
|
package/src/cli.ts
CHANGED
|
@@ -101,8 +101,12 @@ const main = defineCommand({
|
|
|
101
101
|
meta: { name: 'list', description: 'List the clients', hidden: true },
|
|
102
102
|
async run() {
|
|
103
103
|
const clients = await import('./clients');
|
|
104
|
+
const config = await import('./shared/config');
|
|
104
105
|
|
|
105
|
-
await clients.runClients(
|
|
106
|
+
await clients.runClients(
|
|
107
|
+
{ kind: 'list' },
|
|
108
|
+
{ dbPath: config.mcpAuthDBFile, command: 'atc clients' },
|
|
109
|
+
);
|
|
106
110
|
},
|
|
107
111
|
}),
|
|
108
112
|
add: () =>
|
|
@@ -118,12 +122,16 @@ const main = defineCommand({
|
|
|
118
122
|
},
|
|
119
123
|
async run(ctx) {
|
|
120
124
|
const clients = await import('./clients');
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const config = await import('./shared/config');
|
|
126
|
+
|
|
127
|
+
await clients.runClients(
|
|
128
|
+
{
|
|
129
|
+
kind: 'add',
|
|
130
|
+
name: ctx.args.name,
|
|
131
|
+
redirectURIs: collectRedirectURIs(ctx.rawArgs),
|
|
132
|
+
},
|
|
133
|
+
{ dbPath: config.mcpAuthDBFile, command: 'atc clients' },
|
|
134
|
+
);
|
|
127
135
|
},
|
|
128
136
|
}),
|
|
129
137
|
remove: () =>
|
|
@@ -137,8 +145,12 @@ const main = defineCommand({
|
|
|
137
145
|
},
|
|
138
146
|
async run(ctx) {
|
|
139
147
|
const clients = await import('./clients');
|
|
148
|
+
const config = await import('./shared/config');
|
|
140
149
|
|
|
141
|
-
await clients.runClients(
|
|
150
|
+
await clients.runClients(
|
|
151
|
+
{ kind: 'remove', clientID: ctx.args.id },
|
|
152
|
+
{ dbPath: config.mcpAuthDBFile, command: 'atc clients' },
|
|
153
|
+
);
|
|
142
154
|
},
|
|
143
155
|
}),
|
|
144
156
|
},
|
|
@@ -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 [];
|
package/src/clients.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { collectClients } from './mcp/collect-clients';
|
|
3
4
|
import { isAllowedRedirectURI } from './mcp/is-allowed-redirect-uri';
|
|
4
5
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
6
|
import { removeClient } from './mcp/remove-client';
|
|
6
|
-
import { mcpAuthDBFile, stateDir } from './shared/config';
|
|
7
7
|
import { normalizeClientName } from './shared/normalize-client-name';
|
|
8
8
|
|
|
9
9
|
type ClientsAction =
|
|
@@ -11,30 +11,37 @@ type ClientsAction =
|
|
|
11
11
|
| { readonly kind: 'add'; readonly name: string; readonly redirectURIs: readonly string[] }
|
|
12
12
|
| { readonly kind: 'remove'; readonly clientID: string };
|
|
13
13
|
|
|
14
|
+
// The authorization server's database, and the command that prefixes every
|
|
15
|
+
// message, such as `atc clients`.
|
|
16
|
+
interface ClientsTarget {
|
|
17
|
+
readonly dbPath: string;
|
|
18
|
+
readonly command: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/**
|
|
15
|
-
* Runs `atc clients`: lists the clients that may
|
|
16
|
-
* adds one, or removes one along with every
|
|
17
|
-
* opens the authorization server's database
|
|
18
|
-
* not the server is running.
|
|
22
|
+
* Runs `atc clients` or `atc-gateway clients`: lists the clients that may
|
|
23
|
+
* connect to the MCP HTTP server, adds one, or removes one along with every
|
|
24
|
+
* token and consent it holds. It opens the authorization server's database
|
|
25
|
+
* directly, so it works whether or not the server is running.
|
|
19
26
|
*/
|
|
20
|
-
export async function runClients(action: ClientsAction): Promise<void> {
|
|
27
|
+
export async function runClients(action: ClientsAction, target: ClientsTarget): Promise<void> {
|
|
21
28
|
if (action.kind === 'add') {
|
|
22
29
|
const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
|
|
23
30
|
|
|
24
31
|
if (action.redirectURIs.length === 0 || refused !== undefined) {
|
|
25
32
|
const message =
|
|
26
33
|
refused === undefined
|
|
27
|
-
?
|
|
28
|
-
:
|
|
34
|
+
? `${target.command} add: give at least one --redirect-uri`
|
|
35
|
+
: `${target.command} add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
|
|
29
36
|
|
|
30
37
|
console.error(message);
|
|
31
38
|
process.exit(1);
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
mkdirSync(
|
|
42
|
+
mkdirSync(dirname(target.dbPath), { recursive: true });
|
|
36
43
|
|
|
37
|
-
const store = await openMCPAuth({ dbPath:
|
|
44
|
+
const store = await openMCPAuth({ dbPath: target.dbPath, origin: null });
|
|
38
45
|
|
|
39
46
|
try {
|
|
40
47
|
if (action.kind === 'add') {
|
|
@@ -53,7 +60,7 @@ export async function runClients(action: ClientsAction): Promise<void> {
|
|
|
53
60
|
const removed = await removeClient(store.db, action.clientID);
|
|
54
61
|
|
|
55
62
|
if (!removed) {
|
|
56
|
-
console.error(
|
|
63
|
+
console.error(`${target.command} remove: no client has the ID '${action.clientID}'`);
|
|
57
64
|
|
|
58
65
|
process.exitCode = 1;
|
|
59
66
|
|
|
@@ -68,7 +75,7 @@ export async function runClients(action: ClientsAction): Promise<void> {
|
|
|
68
75
|
const clients = await collectClients(store.db);
|
|
69
76
|
|
|
70
77
|
if (clients.length === 0) {
|
|
71
|
-
console.log(
|
|
78
|
+
console.log(`No clients. Add one with: ${target.command} add <name> --redirect-uri <uri>`);
|
|
72
79
|
|
|
73
80
|
return;
|
|
74
81
|
}
|
|
@@ -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;
|