@zgeoff/atc 2.10.4 → 2.12.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/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +371 -99
- package/src/daemon/daemon.ts +537 -90
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +287 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +40 -6
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
|
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
6
|
import type { AgentID } from '../agents/agent-adapter';
|
|
7
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
8
|
+
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
7
9
|
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
8
10
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
9
11
|
import { getBuild } from '../shared/get-build';
|
|
@@ -18,6 +20,9 @@ export interface DaemonBoot {
|
|
|
18
20
|
readonly stale: boolean;
|
|
19
21
|
readonly lastUsedAgent: AgentID;
|
|
20
22
|
|
|
23
|
+
// What the daemon announced it serves; an older daemon announces less.
|
|
24
|
+
readonly features: ReadonlySet<DaemonFeature>;
|
|
25
|
+
|
|
21
26
|
// The socket the client reached, which differs from the computed one when
|
|
22
27
|
// the daemon was found through its record in the state directory.
|
|
23
28
|
readonly socketPath: string;
|
|
@@ -49,6 +54,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
49
54
|
client,
|
|
50
55
|
stale: hello['daemon'] !== build,
|
|
51
56
|
lastUsedAgent: toAgentID(hello['lastUsedAgent']),
|
|
57
|
+
features: parseDaemonFeatures(hello),
|
|
52
58
|
socketPath: opened.socketPath,
|
|
53
59
|
};
|
|
54
60
|
} catch (error) {
|
|
@@ -65,9 +65,12 @@ export class DaemonClient {
|
|
|
65
65
|
return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// A request with a principal acts as that principal, within what the
|
|
69
|
+
// connection may reach.
|
|
68
70
|
sendRequest(
|
|
69
71
|
m: string,
|
|
70
72
|
p?: Readonly<Record<string, unknown>>,
|
|
73
|
+
as?: string,
|
|
71
74
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
72
75
|
if (this.closedReason !== null) {
|
|
73
76
|
return Promise.reject(new DaemonError('internal', this.closedReason));
|
|
@@ -77,7 +80,16 @@ export class DaemonClient {
|
|
|
77
80
|
const resolvers = Promise.withResolvers<Readonly<Record<string, unknown>>>();
|
|
78
81
|
|
|
79
82
|
this.pending.set(id, { resolve: resolvers.resolve, reject: resolvers.reject });
|
|
80
|
-
|
|
83
|
+
|
|
84
|
+
this.queue?.send(
|
|
85
|
+
encodeMessage({
|
|
86
|
+
v: PROTOCOL_V,
|
|
87
|
+
id,
|
|
88
|
+
m,
|
|
89
|
+
...(p === undefined ? {} : { p }),
|
|
90
|
+
...(as === undefined ? {} : { as }),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
81
93
|
|
|
82
94
|
return resolvers.promise;
|
|
83
95
|
}
|
|
@@ -138,7 +150,7 @@ export class DaemonClient {
|
|
|
138
150
|
if (msg.err === undefined) {
|
|
139
151
|
waiter.resolve(msg.ok ?? {});
|
|
140
152
|
} else {
|
|
141
|
-
waiter.reject(new DaemonError(msg.err.code, msg.err.msg));
|
|
153
|
+
waiter.reject(new DaemonError(msg.err.code, msg.err.msg, msg.err.data));
|
|
142
154
|
}
|
|
143
155
|
}
|
|
144
156
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import type { AgentID } from '../agents/agent-adapter';
|
|
3
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
4
|
import { loadConfig } from '../shared/config';
|
|
4
5
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
5
6
|
import type { AgentPick } from './collect-agent-picks';
|
|
@@ -32,7 +33,8 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
32
33
|
/**
|
|
33
34
|
* The modal flow behind n and r: agent, then directory, then name, then an
|
|
34
35
|
* optional first prompt. Every path out of it either attaches the new
|
|
35
|
-
* session or returns the client to the screen it came from.
|
|
36
|
+
* session or returns the client to the screen it came from. A spawn the
|
|
37
|
+
* daemon refuses is no path out: the flow stays open and shows why.
|
|
36
38
|
*/
|
|
37
39
|
export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
38
40
|
private readonly deps: SpawnPickerDeps<TMirror>;
|
|
@@ -60,6 +62,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
60
62
|
|
|
61
63
|
private agent: AgentID = 'claude';
|
|
62
64
|
|
|
65
|
+
// The daemon's refusal of the last spawn, shown in place of the hint until
|
|
66
|
+
// the next key.
|
|
67
|
+
private refusal: string | null = null;
|
|
68
|
+
|
|
63
69
|
constructor(deps: SpawnPickerDeps<TMirror>) {
|
|
64
70
|
this.deps = deps;
|
|
65
71
|
}
|
|
@@ -82,12 +88,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
82
88
|
|
|
83
89
|
this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
|
|
84
90
|
this.step = 'agent';
|
|
91
|
+
this.refusal = null;
|
|
85
92
|
|
|
86
93
|
process.stdout.write(ansi.clear);
|
|
87
94
|
this.render();
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
applyKey(buf: Buffer) {
|
|
98
|
+
this.refusal = null;
|
|
99
|
+
|
|
91
100
|
const edit = planTextEdit(buf, this.input, {
|
|
92
101
|
isLeaderKey: this.deps.isLeaderKey,
|
|
93
102
|
moves: this.step === 'agent' || this.step === 'dir',
|
|
@@ -168,7 +177,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
168
177
|
selected: -1,
|
|
169
178
|
input: this.input,
|
|
170
179
|
placeholder: formatDirName(this.dir),
|
|
171
|
-
hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
|
|
180
|
+
hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
|
|
172
181
|
});
|
|
173
182
|
} else {
|
|
174
183
|
drawPicker({
|
|
@@ -177,7 +186,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
177
186
|
selected: -1,
|
|
178
187
|
input: this.input,
|
|
179
188
|
placeholder: 'optional — ⏎ to start interactive',
|
|
180
|
-
hint: 'first message for the session · ⏎ spawn · esc back',
|
|
189
|
+
hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
|
|
181
190
|
});
|
|
182
191
|
}
|
|
183
192
|
|
|
@@ -320,7 +329,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
320
329
|
|
|
321
330
|
return;
|
|
322
331
|
}
|
|
323
|
-
} catch {
|
|
332
|
+
} catch (error) {
|
|
333
|
+
// A refused spawn keeps the picker on the step that sent it, with the
|
|
334
|
+
// entered text back in the input, so the user can fix the cause and
|
|
335
|
+
// retry or back out.
|
|
336
|
+
const reason =
|
|
337
|
+
error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
|
|
338
|
+
|
|
339
|
+
this.refusal = `${reason} · ⏎ retry · esc back`;
|
|
340
|
+
this.input = this.resume ? this.name : prompt;
|
|
341
|
+
|
|
342
|
+
process.stdout.write(ansi.clear);
|
|
343
|
+
this.render();
|
|
344
|
+
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
324
347
|
|
|
325
348
|
this.deps.toBase();
|
|
326
349
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
2
|
+
|
|
3
|
+
interface AgentCapabilities {
|
|
4
|
+
// Only an installed agent can start a session.
|
|
5
|
+
readonly spawn: boolean;
|
|
6
|
+
readonly readTranscript: boolean;
|
|
7
|
+
|
|
8
|
+
// Whether a session takes inbox messages through a tap.
|
|
9
|
+
readonly message: boolean;
|
|
10
|
+
readonly attach: boolean;
|
|
11
|
+
readonly screen: boolean;
|
|
12
|
+
readonly input: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentEntry {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
|
|
20
|
+
// Whether the agent's binary resolves, on PATH or at its configured path.
|
|
21
|
+
readonly installed: boolean;
|
|
22
|
+
readonly capabilities: AgentCapabilities;
|
|
23
|
+
readonly models: Readonly<Record<string, string>> | null;
|
|
24
|
+
readonly spawnOptions: SpawnOptionEntries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A spawn option as `agents.list` shows it: the agent's own spec, plus
|
|
28
|
+
// whether a spawn on this host can pass it now. Only an installed agent's
|
|
29
|
+
// supported option is available.
|
|
30
|
+
export interface SpawnOptionEntry extends SpawnOptionSpec {
|
|
31
|
+
readonly available: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SpawnOptionEntries {
|
|
35
|
+
readonly model: SpawnOptionEntry;
|
|
36
|
+
readonly effort: SpawnOptionEntry;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One `agents.list` entry per registered adapter, in registration order. An
|
|
41
|
+
* entry carries only the adapter's profile and what its interface offers,
|
|
42
|
+
* so no environment value, credential, helper command, or base URL reaches
|
|
43
|
+
* it. Every session runs in a PTY, so each agent can be attached, read as a
|
|
44
|
+
* screen, and typed into. A stand-in adapter without a profile is listed
|
|
45
|
+
* under its id as both label and kind.
|
|
46
|
+
*/
|
|
47
|
+
export function buildAgentList(
|
|
48
|
+
adapters: readonly AgentAdapter[],
|
|
49
|
+
isInstalled: (bin: string) => boolean,
|
|
50
|
+
): AgentEntry[] {
|
|
51
|
+
return adapters.map((adapter) => {
|
|
52
|
+
const profile = adapter.profile;
|
|
53
|
+
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id: adapter.id,
|
|
57
|
+
label: profile?.label ?? adapter.id,
|
|
58
|
+
kind: profile?.kind ?? adapter.id,
|
|
59
|
+
installed,
|
|
60
|
+
capabilities: {
|
|
61
|
+
spawn: installed,
|
|
62
|
+
readTranscript: adapter.parseTranscriptLine !== undefined,
|
|
63
|
+
message: adapter.takesMessages,
|
|
64
|
+
attach: true,
|
|
65
|
+
screen: true,
|
|
66
|
+
input: true,
|
|
67
|
+
},
|
|
68
|
+
models: profile?.models ?? null,
|
|
69
|
+
spawnOptions: {
|
|
70
|
+
model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
|
|
71
|
+
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A stand-in adapter declares no spawn options, so it takes none.
|
|
78
|
+
const NO_SPAWN_OPTION: SpawnOptionSpec = {
|
|
79
|
+
supported: false,
|
|
80
|
+
values: null,
|
|
81
|
+
examples: [],
|
|
82
|
+
default: null,
|
|
83
|
+
backendEffect: null,
|
|
84
|
+
note: null,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function buildSpawnOptionEntry(
|
|
88
|
+
spec: SpawnOptionSpec | undefined,
|
|
89
|
+
installed: boolean,
|
|
90
|
+
): SpawnOptionEntry {
|
|
91
|
+
const resolved = spec ?? NO_SPAWN_OPTION;
|
|
92
|
+
|
|
93
|
+
return { ...resolved, available: installed && resolved.supported };
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
3
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A short digest of the target config a daemon runs with: each target's id
|
|
7
|
+
* and identity, the default target, and the config errors. Two daemons with
|
|
8
|
+
* the same targets return the same revision, and any change to them
|
|
9
|
+
* changes it.
|
|
10
|
+
*/
|
|
11
|
+
export function buildConfigRevision(
|
|
12
|
+
targets: readonly ExecutionTarget[],
|
|
13
|
+
defaultTarget: string | null,
|
|
14
|
+
errors: readonly TargetConfigError[],
|
|
15
|
+
): string {
|
|
16
|
+
const canonical = JSON.stringify({
|
|
17
|
+
targets: targets.map((target) => [target.id, target.identity]),
|
|
18
|
+
defaultTarget,
|
|
19
|
+
errors: errors.map((error) =>
|
|
20
|
+
error.scope === 'config'
|
|
21
|
+
? [error.scope, error.path, error.problem, error.detail]
|
|
22
|
+
: [error.scope, error.target ?? null, error.problem],
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TargetConfig } from '../shared/collect-targets';
|
|
2
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
3
|
+
import type { ExecutionProvider } from './execution-provider';
|
|
4
|
+
import { LocalPTYProvider } from './local-pty-provider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A configured place sessions run, with the provider that serves it. The
|
|
8
|
+
* provider is null when this daemon has no provider of the configured kind:
|
|
9
|
+
* the target still lists, and a spawn to it fails rather than running
|
|
10
|
+
* anywhere else.
|
|
11
|
+
*/
|
|
12
|
+
export interface ExecutionTarget {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly kind: string;
|
|
15
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
16
|
+
|
|
17
|
+
// The identity a session spawned here binds to.
|
|
18
|
+
readonly identity: string;
|
|
19
|
+
readonly provider: ExecutionProvider | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds one target per configured entry, in config order, each with a
|
|
24
|
+
* provider of its kind when this daemon has one.
|
|
25
|
+
*/
|
|
26
|
+
export function buildExecutionTargets(configs: readonly TargetConfig[]): ExecutionTarget[] {
|
|
27
|
+
return configs.map((config) => ({
|
|
28
|
+
id: config.id,
|
|
29
|
+
kind: config.provider,
|
|
30
|
+
options: config.options,
|
|
31
|
+
identity: buildTargetIdentity(config.provider, config.options),
|
|
32
|
+
provider: config.provider === 'local-pty' ? new LocalPTYProvider() : null,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
@@ -23,7 +23,8 @@ export function buildFleetEvents(
|
|
|
23
23
|
sessions: readonly SessionDescriptor[],
|
|
24
24
|
): FleetEvent[] {
|
|
25
25
|
return rows.map((row) => {
|
|
26
|
-
//
|
|
26
|
+
// Rows written before atc session ids stayed stable across restores
|
|
27
|
+
// carry an earlier atc id, so the agent session id links them.
|
|
27
28
|
const live =
|
|
28
29
|
(row.agentSessionID === null
|
|
29
30
|
? undefined
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The SHA-256 of a request's params as canonical JSON: object keys sorted
|
|
6
|
+
* at every depth, with the idempotency key itself left out. Two requests
|
|
7
|
+
* that differ only in key order or in their idempotency key hash the same.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
|
|
10
|
+
const { idempotencyKey: _ignored, ...payload } = params;
|
|
11
|
+
|
|
12
|
+
return createHash('sha256')
|
|
13
|
+
.update(JSON.stringify(sortKeys(payload)))
|
|
14
|
+
.digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sortKeys(value: unknown): unknown {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map((item) => sortKeys(item));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!isRecord(value)) {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.keys(value)
|
|
28
|
+
.toSorted()
|
|
29
|
+
.map((key) => [key, sortKeys(value[key])]),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
4
|
+
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
5
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
6
|
+
import type { DaemonContext } from './daemon-connection';
|
|
7
|
+
import type { KeyedRequest } from './idempotency-ledger';
|
|
8
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The daemon as a principal with the given access sees it. Its keyed
|
|
12
|
+
* spawns and messages hold their keys in the given namespace, apart from
|
|
13
|
+
* every other namespace's. A session on a target outside the access does
|
|
14
|
+
* not exist here: every lookup of it answers as a lookup of an unknown
|
|
15
|
+
* session does, so the request's own handling refuses it with the words
|
|
16
|
+
* and data it gives a session that never existed. Lists leave such
|
|
17
|
+
* sessions out, and a spawn may use only a target the access holds, a
|
|
18
|
+
* spawn's replayed answer included. A kill, or a pin, of a session whose
|
|
19
|
+
* sub-sessions reach past the access is refused whole, before anything
|
|
20
|
+
* changes.
|
|
21
|
+
*/
|
|
22
|
+
export function buildScopedContext(
|
|
23
|
+
ctx: DaemonContext,
|
|
24
|
+
access: TargetAccess,
|
|
25
|
+
keyNamespace: string,
|
|
26
|
+
): DaemonContext {
|
|
27
|
+
// A keyed request's key, held in this namespace alone.
|
|
28
|
+
const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
|
|
29
|
+
keyed === null ? null : { ...keyed, principal: keyNamespace };
|
|
30
|
+
|
|
31
|
+
const canSee = (id: SessionID): boolean => {
|
|
32
|
+
const grant = ctx.findSessionGrant(id);
|
|
33
|
+
|
|
34
|
+
return grant !== null && access.canUse(grant);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const canUseTarget = (target: string): boolean => {
|
|
38
|
+
const targetIdentity = ctx.findTargetIdentity(target);
|
|
39
|
+
|
|
40
|
+
return targetIdentity !== null && access.canUse({ target, targetIdentity });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The target a session is bound to: the live session's, else its fleet
|
|
44
|
+
// row's, else null for a session the daemon holds nowhere.
|
|
45
|
+
const findGrant = async (id: SessionID): Promise<TargetGrant | null> => {
|
|
46
|
+
const live = ctx.findSessionGrant(id);
|
|
47
|
+
|
|
48
|
+
if (live !== null) {
|
|
49
|
+
return live;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fleet = await ctx.collectFleet();
|
|
53
|
+
|
|
54
|
+
const entry = fleet.find((row) => row.sessionID === id);
|
|
55
|
+
|
|
56
|
+
if (entry === undefined) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
target: entry.target ?? 'local',
|
|
62
|
+
targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Throws the refusal a fresh spawn to the session's target gets when a
|
|
67
|
+
// held key's refusal carries a session out of reach.
|
|
68
|
+
const requireSessionInReach = async (id: SessionID): Promise<void> => {
|
|
69
|
+
const grant = await findGrant(id);
|
|
70
|
+
|
|
71
|
+
if (grant !== null && !access.canUse(grant)) {
|
|
72
|
+
throw buildTargetForbiddenError(grant.target);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Throws the refusal of a change to a session whose sub-sessions the
|
|
77
|
+
// change would reach too when any of them is out of reach. The refusal
|
|
78
|
+
// holds only the session the caller sent, never a sub-session's id or
|
|
79
|
+
// how many there are.
|
|
80
|
+
const requireTreeInReach = (id: SessionID, action: string): void => {
|
|
81
|
+
if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new DaemonError(
|
|
86
|
+
'target_forbidden',
|
|
87
|
+
`this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
|
|
88
|
+
{ session: id },
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
...ctx,
|
|
94
|
+
collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
|
|
95
|
+
collectAgents: () => {
|
|
96
|
+
const list = ctx.collectAgents();
|
|
97
|
+
const defaultTarget = list.spawnDefaults.target;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...list,
|
|
101
|
+
targets: list.targets.filter((target) => canUseTarget(target.id)),
|
|
102
|
+
spawnDefaults: {
|
|
103
|
+
...list.spawnDefaults,
|
|
104
|
+
target: defaultTarget !== null && canUseTarget(defaultTarget) ? defaultTarget : null,
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// The config file's own problem holds a path on the daemon's host,
|
|
108
|
+
// which is the owner's alone.
|
|
109
|
+
targetErrors: list.targetErrors.filter((error) => {
|
|
110
|
+
if (error.scope === 'config') {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return error.target === undefined || canUseTarget(error.target);
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
collectFleet: async () => {
|
|
119
|
+
const fleet = await ctx.collectFleet();
|
|
120
|
+
|
|
121
|
+
return fleet.filter((entry) => canSee(entry.sessionID));
|
|
122
|
+
},
|
|
123
|
+
resolveSpawnTarget: (requested) => {
|
|
124
|
+
if (requested !== undefined) {
|
|
125
|
+
requireTarget(canUseTarget, requested);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const target = ctx.resolveSpawnTarget(requested);
|
|
129
|
+
|
|
130
|
+
requireTarget(canUseTarget, target);
|
|
131
|
+
|
|
132
|
+
return target;
|
|
133
|
+
},
|
|
134
|
+
spawnSession: async (plan, keyed) => {
|
|
135
|
+
let answer: Readonly<Record<string, unknown>>;
|
|
136
|
+
|
|
137
|
+
// A held key's replay is checked against the target its session was
|
|
138
|
+
// bound to, inside the spawn, before its answer leaves the daemon.
|
|
139
|
+
try {
|
|
140
|
+
answer = await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// A held key's refusal may carry the session its spawn made.
|
|
143
|
+
if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
|
|
144
|
+
await requireSessionInReach(toSessionID(error.data['effectRef']));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return answer;
|
|
151
|
+
},
|
|
152
|
+
killSession: (id) => {
|
|
153
|
+
if (!canSee(id)) {
|
|
154
|
+
return Promise.resolve(false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
requireTreeInReach(id, 'kill');
|
|
158
|
+
|
|
159
|
+
return ctx.killSession(id);
|
|
160
|
+
},
|
|
161
|
+
updateSession: (id, name, pinned) => {
|
|
162
|
+
if (!canSee(id)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (pinned !== undefined) {
|
|
167
|
+
requireTreeInReach(id, 'pin');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return ctx.updateSession(id, name, pinned);
|
|
171
|
+
},
|
|
172
|
+
ackSession: (id) => canSee(id) && ctx.ackSession(id),
|
|
173
|
+
buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
|
|
174
|
+
readSessionScreen: (id) =>
|
|
175
|
+
canSee(id) ? ctx.readSessionScreen(id) : Promise.resolve('missing' as const),
|
|
176
|
+
answerPermission: (request, decision) => {
|
|
177
|
+
const owner = ctx.findPermissionSession(request);
|
|
178
|
+
|
|
179
|
+
return owner !== null && canSee(owner) ? ctx.answerPermission(request, decision) : 'unknown';
|
|
180
|
+
},
|
|
181
|
+
attachSession: (client, sessionID, dims) =>
|
|
182
|
+
canSee(sessionID) ? ctx.attachSession(client, sessionID, dims) : 'missing',
|
|
183
|
+
detachSession: (client, sessionID) => {
|
|
184
|
+
if (canSee(sessionID)) {
|
|
185
|
+
ctx.detachSession(client, sessionID);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
writeSessionInput: (sessionID, data) =>
|
|
189
|
+
canSee(sessionID) ? ctx.writeSessionInput(sessionID, data) : 'missing',
|
|
190
|
+
ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
|
|
191
|
+
adoptSession: (id, cols, rows) => (canSee(id) ? ctx.adoptSession(id, cols, rows) : 'missing'),
|
|
192
|
+
resizeSession: (client, sessionID, dims) =>
|
|
193
|
+
canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
|
|
194
|
+
readSessionRecord: (id) =>
|
|
195
|
+
canSee(id) ? ctx.readSessionRecord(id) : Promise.resolve('missing' as const),
|
|
196
|
+
loadSessionTranscript: (id, from, limit) =>
|
|
197
|
+
canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
|
|
198
|
+
readEvents: (afterID, limit, waitMs, sessionID, outer) => {
|
|
199
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
200
|
+
|
|
201
|
+
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
202
|
+
},
|
|
203
|
+
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
204
|
+
canSee(sessionID)
|
|
205
|
+
? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
|
|
206
|
+
: Promise.resolve('missing' as const),
|
|
207
|
+
readMessage: async (messageID, waitMs) => {
|
|
208
|
+
// The owner is checked before any wait, so a message outside the
|
|
209
|
+
// access answers at once, as an unknown message does.
|
|
210
|
+
const view = await ctx.readMessage(messageID, 0);
|
|
211
|
+
|
|
212
|
+
if (view === null || !canSee(view.session)) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return waitMs === 0 ? view : ctx.readMessage(messageID, waitMs);
|
|
217
|
+
},
|
|
218
|
+
attachTap: (client, sessionID) =>
|
|
219
|
+
canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
|
|
220
|
+
ackMessage: (client, sessionID, messageID) =>
|
|
221
|
+
canSee(sessionID)
|
|
222
|
+
? ctx.ackMessage(client, sessionID, messageID)
|
|
223
|
+
: Promise.resolve('unknown' as const),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Throws the refusal of a spawn to a target the access leaves out.
|
|
228
|
+
function requireTarget(canUseTarget: (target: string) => boolean, target: string): void {
|
|
229
|
+
if (!canUseTarget(target)) {
|
|
230
|
+
throw buildTargetForbiddenError(target);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
2
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
3
|
+
import { TargetAccess } from './target-access';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The targets a principal may use. With no principals in the config, every
|
|
7
|
+
* principal gets the implicit `local` target alone: the name `local` with
|
|
8
|
+
* the identity of a `local-pty` target without options, so a `local` that
|
|
9
|
+
* now holds another provider or other options is outside it. With
|
|
10
|
+
* principals, a principal gets each target its entry lists that the config
|
|
11
|
+
* holds, at that target's identity now, and an unknown principal gets none.
|
|
12
|
+
*/
|
|
13
|
+
export function buildTargetAccess(
|
|
14
|
+
principals: ReadonlyMap<string, readonly string[]> | null,
|
|
15
|
+
targets: ReadonlyMap<string, ExecutionTarget>,
|
|
16
|
+
principal: string,
|
|
17
|
+
): TargetAccess {
|
|
18
|
+
if (principals === null) {
|
|
19
|
+
return new TargetAccess([
|
|
20
|
+
{ target: 'local', targetIdentity: buildTargetIdentity('local-pty', {}) },
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const granted = principals.get(principal) ?? [];
|
|
25
|
+
|
|
26
|
+
return new TargetAccess(
|
|
27
|
+
granted.flatMap((id) => {
|
|
28
|
+
const target = targets.get(id);
|
|
29
|
+
|
|
30
|
+
return target === undefined ? [] : [{ target: id, targetIdentity: target.identity }];
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The refusal of a spawn to a target the principal may not use, which a
|
|
5
|
+
* replay of a held spawn key outside the principal's reach answers with too.
|
|
6
|
+
*/
|
|
7
|
+
export function buildTargetForbiddenError(target: string): DaemonError {
|
|
8
|
+
return new DaemonError(
|
|
9
|
+
'target_forbidden',
|
|
10
|
+
`this client may not use execution target '${target}'. Grant it to the client under principals in config.json and restart the daemon`,
|
|
11
|
+
{ target },
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { sortJSONKeys } from '../shared/sort-json-keys';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The identity a session binds to when it spawns on a target: the provider
|
|
6
|
+
* kind, a colon, and the first 16 hex digits of a sha256 over the target's
|
|
7
|
+
* options with sorted keys. A target name reused with another provider or
|
|
8
|
+
* other options has another identity. Target options hold no credential
|
|
9
|
+
* values, only references such as an environment variable's name, so the
|
|
10
|
+
* identity never derives from a secret.
|
|
11
|
+
*/
|
|
12
|
+
export function buildTargetIdentity(
|
|
13
|
+
kind: string,
|
|
14
|
+
options: Readonly<Record<string, unknown>>,
|
|
15
|
+
): string {
|
|
16
|
+
const digest = createHash('sha256')
|
|
17
|
+
.update(JSON.stringify(sortJSONKeys(options)))
|
|
18
|
+
.digest('hex')
|
|
19
|
+
.slice(0, 16);
|
|
20
|
+
|
|
21
|
+
return `${kind}:${digest}`;
|
|
22
|
+
}
|