@zgeoff/atc 2.11.0 → 2.13.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 +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -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 +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- 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 +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -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/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -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
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
writeSessionLine: (sessionID, text) =>
|
|
191
|
+
canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
|
|
192
|
+
ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
|
|
193
|
+
adoptSession: (id, cols, rows) =>
|
|
194
|
+
canSee(id) ? ctx.adoptSession(id, cols, rows) : Promise.resolve('missing' as const),
|
|
195
|
+
resizeSession: (client, sessionID, dims) =>
|
|
196
|
+
canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
|
|
197
|
+
readSessionRecord: (id) =>
|
|
198
|
+
canSee(id) ? ctx.readSessionRecord(id) : Promise.resolve('missing' as const),
|
|
199
|
+
loadSessionTranscript: (id, from, limit) =>
|
|
200
|
+
canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
|
|
201
|
+
readEvents: (afterID, limit, waitMs, sessionID, outer) => {
|
|
202
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
203
|
+
|
|
204
|
+
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
205
|
+
},
|
|
206
|
+
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
207
|
+
canSee(sessionID)
|
|
208
|
+
? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
|
|
209
|
+
: Promise.resolve('missing' as const),
|
|
210
|
+
readMessage: async (messageID, waitMs) => {
|
|
211
|
+
// The owner is checked before any wait, so a message outside the
|
|
212
|
+
// access answers at once, as an unknown message does.
|
|
213
|
+
const view = await ctx.readMessage(messageID, 0);
|
|
214
|
+
|
|
215
|
+
if (view === null || !canSee(view.session)) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return waitMs === 0 ? view : ctx.readMessage(messageID, waitMs);
|
|
220
|
+
},
|
|
221
|
+
attachTap: (client, sessionID) =>
|
|
222
|
+
canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
|
|
223
|
+
ackMessage: (client, sessionID, messageID) =>
|
|
224
|
+
canSee(sessionID)
|
|
225
|
+
? ctx.ackMessage(client, sessionID, messageID)
|
|
226
|
+
: Promise.resolve('unknown' as const),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Throws the refusal of a spawn to a target the access leaves out.
|
|
231
|
+
function requireTarget(canUseTarget: (target: string) => boolean, target: string): void {
|
|
232
|
+
if (!canUseTarget(target)) {
|
|
233
|
+
throw buildTargetForbiddenError(target);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A session's lifecycle as four layers, each owned by one actor:
|
|
3
|
+
*
|
|
4
|
+
* - `desired`: what the operator asked for. `run` keeps the harness
|
|
5
|
+
* running, `sleep` keeps its host asleep with the harness inside it, and
|
|
6
|
+
* `stop` leaves the harness ended.
|
|
7
|
+
* - `vm`: the host the harness runs on. `none` is the daemon's own machine,
|
|
8
|
+
* which has no lifecycle of its own; `awake`, `asleep`, and `unknown` are
|
|
9
|
+
* the last state the daemon saw of a remote host.
|
|
10
|
+
* - `harness`: the agent process. `suspended` is a process kept inside a
|
|
11
|
+
* sleeping host, which a revive brings back as it was.
|
|
12
|
+
* - `attachment`: the daemon's own connection to the harness's output.
|
|
13
|
+
* `local` is a harness in the daemon's process tree; `attached`,
|
|
14
|
+
* `reattaching`, and `detached` follow the connection to a remote one.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionLifecycle {
|
|
17
|
+
readonly desired: 'run' | 'sleep' | 'stop';
|
|
18
|
+
readonly vm: 'none' | 'awake' | 'asleep' | 'unknown';
|
|
19
|
+
readonly harness: 'running' | 'suspended' | 'exited';
|
|
20
|
+
readonly attachment: 'local' | 'attached' | 'reattaching' | 'detached';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The session facts the layers derive from.
|
|
24
|
+
interface LifecycleFacts {
|
|
25
|
+
readonly desired: SessionLifecycle['desired'];
|
|
26
|
+
readonly vm: SessionLifecycle['vm'];
|
|
27
|
+
readonly attachment: SessionLifecycle['attachment'];
|
|
28
|
+
|
|
29
|
+
// Whether the harness is kept inside a sleeping host.
|
|
30
|
+
readonly suspended: boolean;
|
|
31
|
+
readonly hasHarness: boolean;
|
|
32
|
+
readonly kind: 'pty' | 'headless';
|
|
33
|
+
readonly state: 'running' | 'needs_you' | 'done' | 'exited';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds the four layers from a session's facts. The harness runs while the
|
|
38
|
+
* session holds a terminal or a live headless run. A harness without one is
|
|
39
|
+
* suspended while it is kept inside a sleeping host, and exited otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSessionLifecycle(facts: Readonly<LifecycleFacts>): SessionLifecycle {
|
|
42
|
+
const running = facts.hasHarness || (facts.kind === 'headless' && facts.state !== 'exited');
|
|
43
|
+
let harness: SessionLifecycle['harness'] = 'exited';
|
|
44
|
+
|
|
45
|
+
if (running) {
|
|
46
|
+
harness = 'running';
|
|
47
|
+
} else if (facts.suspended && facts.vm === 'asleep') {
|
|
48
|
+
harness = 'suspended';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { desired: facts.desired, vm: facts.vm, harness, attachment: facts.attachment };
|
|
52
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One file in an archive: its path relative to the directory it unpacks
|
|
3
|
+
* into, its bytes, and its permission bits.
|
|
4
|
+
*/
|
|
5
|
+
export interface ArchiveFile {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly content: string | Uint8Array;
|
|
8
|
+
readonly mode?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BLOCK = 512;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
|
|
15
|
+
* its parent directories created on the way. A path longer than ustar
|
|
16
|
+
* holds is refused.
|
|
17
|
+
*/
|
|
18
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
|
|
19
|
+
export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
const blocks: Uint8Array[] = [];
|
|
23
|
+
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const content = typeof file.content === 'string' ? encoder.encode(file.content) : file.content;
|
|
26
|
+
|
|
27
|
+
blocks.push(
|
|
28
|
+
buildHeader(file.path, content.length, file.mode ?? 0o644),
|
|
29
|
+
content,
|
|
30
|
+
new Uint8Array((BLOCK - (content.length % BLOCK)) % BLOCK),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Two empty blocks end the archive.
|
|
35
|
+
blocks.push(new Uint8Array(BLOCK * 2));
|
|
36
|
+
|
|
37
|
+
const archive = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0));
|
|
38
|
+
|
|
39
|
+
let at = 0;
|
|
40
|
+
|
|
41
|
+
for (const block of blocks) {
|
|
42
|
+
archive.set(block, at);
|
|
43
|
+
|
|
44
|
+
at += block.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return archive;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
|
|
53
|
+
const name = encoder.encode(path);
|
|
54
|
+
|
|
55
|
+
if (name.length > 100) {
|
|
56
|
+
throw new Error(`archive path is longer than 100 bytes: ${path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const header = new Uint8Array(BLOCK);
|
|
60
|
+
|
|
61
|
+
header.set(name, 0);
|
|
62
|
+
header.set(encoder.encode(formatOctal(mode, 8)), 100);
|
|
63
|
+
header.set(encoder.encode(formatOctal(0, 8)), 108);
|
|
64
|
+
header.set(encoder.encode(formatOctal(0, 8)), 116);
|
|
65
|
+
header.set(encoder.encode(formatOctal(size, 12)), 124);
|
|
66
|
+
header.set(encoder.encode(formatOctal(0, 12)), 136);
|
|
67
|
+
|
|
68
|
+
// The checksum counts its own field as spaces.
|
|
69
|
+
header.set(encoder.encode(' '), 148);
|
|
70
|
+
|
|
71
|
+
header[156] = '0'.codePointAt(0) ?? 0;
|
|
72
|
+
|
|
73
|
+
header.set(encoder.encode('ustar\u000000'), 257);
|
|
74
|
+
|
|
75
|
+
const checksum = header.reduce((total, byte) => total + byte, 0);
|
|
76
|
+
|
|
77
|
+
header.set(encoder.encode(`${checksum.toString(8).padStart(6, '0')}\u0000 `), 148);
|
|
78
|
+
|
|
79
|
+
return header;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An octal number padded to fill its field, ending in a NUL.
|
|
83
|
+
function formatOctal(value: number, width: number): string {
|
|
84
|
+
return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
|
|
85
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
2
|
+
import type { ExecutionCapabilities } from './execution-provider';
|
|
3
|
+
|
|
4
|
+
export interface TargetEntry {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
|
|
7
|
+
// The provider kind the config selects for the target.
|
|
8
|
+
readonly provider: string;
|
|
9
|
+
|
|
10
|
+
// The identity a session spawned on the target binds to.
|
|
11
|
+
readonly identity: string;
|
|
12
|
+
|
|
13
|
+
// Whether this daemon has a provider of that kind, so a spawn can run there.
|
|
14
|
+
readonly available: boolean;
|
|
15
|
+
readonly default: boolean;
|
|
16
|
+
readonly capabilities: ExecutionCapabilities;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// What a target without a provider can do: nothing.
|
|
20
|
+
const NO_CAPABILITIES: ExecutionCapabilities = {
|
|
21
|
+
spawn: false,
|
|
22
|
+
attach: false,
|
|
23
|
+
input: false,
|
|
24
|
+
resize: false,
|
|
25
|
+
kill: false,
|
|
26
|
+
transfer: false,
|
|
27
|
+
run: false,
|
|
28
|
+
headless: false,
|
|
29
|
+
suspend: false,
|
|
30
|
+
destroy: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One `agents.list` target entry per configured target, in config order.
|
|
35
|
+
* An entry holds the target's id, provider kind, and capabilities, never its
|
|
36
|
+
* options, since those can hold a host's address or an account.
|
|
37
|
+
*/
|
|
38
|
+
export function buildTargetList(
|
|
39
|
+
targets: readonly ExecutionTarget[],
|
|
40
|
+
defaultTarget: string | null,
|
|
41
|
+
): TargetEntry[] {
|
|
42
|
+
return targets.map((target) => ({
|
|
43
|
+
id: target.id,
|
|
44
|
+
provider: target.kind,
|
|
45
|
+
identity: target.identity,
|
|
46
|
+
available: target.provider !== null,
|
|
47
|
+
default: target.id === defaultTarget,
|
|
48
|
+
capabilities: target.provider?.capabilities ?? NO_CAPABILITIES,
|
|
49
|
+
}));
|
|
50
|
+
}
|