@zgeoff/atc 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/daemon/build-scoped-context.ts +4 -2
- package/src/daemon/daemon-connection.ts +4 -1
- package/src/daemon/daemon-context.ts +12 -1
- package/src/daemon/daemon.ts +19 -1
- package/src/daemon/local-pty-provider.ts +83 -10
- package/src/daemon/read-native-env-keys.ts +74 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +70 -15
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
package/package.json
CHANGED
|
@@ -137,8 +137,10 @@ export function buildScopedContext(
|
|
|
137
137
|
|
|
138
138
|
// A session out of reach answers before any confirm token is handed
|
|
139
139
|
// out or taken, so its host is never touched.
|
|
140
|
-
forgetSession: (id, confirmToken) =>
|
|
141
|
-
canSee(id)
|
|
140
|
+
forgetSession: (id, confirmToken, refuse) =>
|
|
141
|
+
canSee(id)
|
|
142
|
+
? ctx.forgetSession(id, confirmToken, refuse)
|
|
143
|
+
: Promise.resolve('missing' as const),
|
|
142
144
|
revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
|
|
143
145
|
updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
|
|
144
146
|
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
@@ -650,7 +650,10 @@ export class DaemonConnection {
|
|
|
650
650
|
|
|
651
651
|
const id = parsed.data.session;
|
|
652
652
|
|
|
653
|
-
const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken
|
|
653
|
+
const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken, {
|
|
654
|
+
pinned: parsed.data.refusePinned === true,
|
|
655
|
+
live: parsed.data.refuseLive === true,
|
|
656
|
+
});
|
|
654
657
|
|
|
655
658
|
if (forgotten === 'missing') {
|
|
656
659
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -63,6 +63,15 @@ type ForgetResult =
|
|
|
63
63
|
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
64
64
|
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The states a forget refuses to act on: a pinned session, or a sub-session
|
|
68
|
+
* of a pinned one, and a live session.
|
|
69
|
+
*/
|
|
70
|
+
interface ForgetRefusals {
|
|
71
|
+
readonly pinned: boolean;
|
|
72
|
+
readonly live: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
// A transcript page with the file it came from, so a cursor into a replaced
|
|
67
76
|
// transcript is distinguishable from one into a grown transcript.
|
|
68
77
|
interface SessionTranscriptRead {
|
|
@@ -223,10 +232,12 @@ export interface DaemonContext {
|
|
|
223
232
|
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
224
233
|
|
|
225
234
|
// Forgets a session, or answers with the token a forget that destroys a
|
|
226
|
-
// host must carry. Throws the refusal for a token it does not take
|
|
235
|
+
// host must carry. Throws the refusal for a token it does not take, and
|
|
236
|
+
// for a pinned or live session when the caller asks it to refuse one.
|
|
227
237
|
readonly forgetSession: (
|
|
228
238
|
id: SessionID,
|
|
229
239
|
confirmToken: string | undefined,
|
|
240
|
+
refuse: ForgetRefusals,
|
|
230
241
|
) => Promise<ForgetResult | 'missing'>;
|
|
231
242
|
|
|
232
243
|
// Withdraws the grants of the runtime auth binding on a session's host,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1573,13 +1573,31 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1573
1573
|
// A forget on a target that cannot destroy its host forgets at once. On
|
|
1574
1574
|
// one that can, a forget without a token checks the target and answers
|
|
1575
1575
|
// with a token, and the forget that carries the token destroys the host.
|
|
1576
|
-
|
|
1576
|
+
// A refusal the caller asks for reads the session in the same step that
|
|
1577
|
+
// starts the forget, so a pin or revive that lands first is seen.
|
|
1578
|
+
forgetSession: async (id, confirmToken, refuse) => {
|
|
1577
1579
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1578
1580
|
|
|
1579
1581
|
if (s === undefined) {
|
|
1580
1582
|
return 'missing';
|
|
1581
1583
|
}
|
|
1582
1584
|
|
|
1585
|
+
if (refuse.pinned && (s.pinned || mgr.sessions.some((x) => x.id === s.parent && x.pinned))) {
|
|
1586
|
+
throw new DaemonError(
|
|
1587
|
+
'session_pinned',
|
|
1588
|
+
`session ${id} is pinned, or is a sub-session of a pinned session; unpin it before forgetting it`,
|
|
1589
|
+
{ session: id },
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (refuse.live && (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
1594
|
+
throw new DaemonError(
|
|
1595
|
+
'session_live',
|
|
1596
|
+
`session ${id} is live; stop it before forgetting it`,
|
|
1597
|
+
{ session: id },
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1583
1601
|
if (mgr.findProvider(s)?.capabilities.destroy === true) {
|
|
1584
1602
|
mgr.requireExecution(s, 'destroy');
|
|
1585
1603
|
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
HarnessHandle,
|
|
11
11
|
HarnessSpec,
|
|
12
12
|
} from './execution-provider';
|
|
13
|
+
import { readNativeEnvKeys } from './read-native-env-keys';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The `local-pty` provider: harnesses run as child processes of the daemon
|
|
@@ -51,13 +52,21 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const env = buildPTYEnv(spec);
|
|
56
|
+
const bin = resolveHarnessBin(spec.bin, env, spec.cwd);
|
|
57
|
+
const unset = buildUnsetNames(env, [...readNativeEnvKeys(), ...Object.keys(process.env)]);
|
|
58
|
+
|
|
59
|
+
const pty = spawn(
|
|
60
|
+
ENV_BIN,
|
|
61
|
+
buildEnvArgs(unset, buildDYLDEntries(env, process.platform), bin, spec.args),
|
|
62
|
+
{
|
|
63
|
+
name: 'xterm-256color',
|
|
64
|
+
cols: spec.cols,
|
|
65
|
+
rows: spec.rows,
|
|
66
|
+
cwd: spec.cwd,
|
|
67
|
+
env,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
61
70
|
|
|
62
71
|
const subscriptions = new Set<{ readonly dispose: () => void }>();
|
|
63
72
|
|
|
@@ -185,9 +194,7 @@ const USABLE_TERM = 'xterm-256color';
|
|
|
185
194
|
|
|
186
195
|
// A pseudo-terminal always has a terminal on its far side, so a harness never
|
|
187
196
|
// starts with an empty or dumb TERM, which leaves an agent CLI drawing with no
|
|
188
|
-
// colour.
|
|
189
|
-
// daemon itself started with, and the keys passed here only add to it or
|
|
190
|
-
// override it, so leaving TERM out hands the child the daemon's own value.
|
|
197
|
+
// colour.
|
|
191
198
|
function buildPTYEnv(spec: HarnessSpec): Record<string, string> {
|
|
192
199
|
const env = collectCleanEnv(spec.env, spec.withheldEnv);
|
|
193
200
|
const term = env['TERM'];
|
|
@@ -198,6 +205,72 @@ function buildPTYEnv(spec: HarnessSpec): Record<string, string> {
|
|
|
198
205
|
};
|
|
199
206
|
}
|
|
200
207
|
|
|
208
|
+
// The PTY library starts its child from the environment the daemon started
|
|
209
|
+
// with and lays the map over it, so the harness starts behind `env`, which
|
|
210
|
+
// unsets every name the map leaves out before it replaces itself with the
|
|
211
|
+
// harness. The pid stays the harness's own.
|
|
212
|
+
const ENV_BIN = '/usr/bin/env';
|
|
213
|
+
|
|
214
|
+
// The program is found on the map's PATH before the harness starts, so a
|
|
215
|
+
// missing program fails the spawn as the PTY library fails it, instead of
|
|
216
|
+
// starting `env` only to exit.
|
|
217
|
+
function resolveHarnessBin(
|
|
218
|
+
bin: string,
|
|
219
|
+
env: Readonly<Record<string, string>>,
|
|
220
|
+
cwd: string,
|
|
221
|
+
): string {
|
|
222
|
+
const resolved = Bun.which(bin, { PATH: env['PATH'] ?? '', cwd });
|
|
223
|
+
|
|
224
|
+
if (resolved === null) {
|
|
225
|
+
throw new Error(`PTY spawn failed: ${bin} is not a program the harness can start`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return resolved;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A name the native environment block cannot hold, such as one with `=`,
|
|
232
|
+
// is left alone, since `env` refuses to unset it.
|
|
233
|
+
function buildUnsetNames(
|
|
234
|
+
env: Readonly<Record<string, string>>,
|
|
235
|
+
inherited: readonly string[],
|
|
236
|
+
): string[] {
|
|
237
|
+
return [...new Set(inherited)].filter(
|
|
238
|
+
(name) => name !== '' && !name.includes('=') && !Object.hasOwn(env, name),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// macOS clears every `DYLD_` variable on the way into `env`, a protected
|
|
243
|
+
// system program, so the map's own ones are set again after the unsets, the
|
|
244
|
+
// one place the argv holds a value.
|
|
245
|
+
function buildDYLDEntries(
|
|
246
|
+
env: Readonly<Record<string, string>>,
|
|
247
|
+
platform: NodeJS.Platform,
|
|
248
|
+
): string[] {
|
|
249
|
+
if (platform !== 'darwin') {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return Object.entries(env)
|
|
254
|
+
.filter(([name]) => name.startsWith('DYLD_'))
|
|
255
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// `env` reads a word holding `=` as an assignment even after `--`, so a
|
|
259
|
+
// program path holding one starts through a shell that runs it by its first
|
|
260
|
+
// argument.
|
|
261
|
+
const SHELL_EXEC = 'exec "$0" "$@"';
|
|
262
|
+
|
|
263
|
+
function buildEnvArgs(
|
|
264
|
+
unset: readonly string[],
|
|
265
|
+
entries: readonly string[],
|
|
266
|
+
bin: string,
|
|
267
|
+
args: readonly string[],
|
|
268
|
+
): string[] {
|
|
269
|
+
const program = bin.includes('=') ? ['/bin/sh', '-c', SHELL_EXEC, bin] : [bin];
|
|
270
|
+
|
|
271
|
+
return [...unset.flatMap((name) => ['-u', name]), '--', ...entries, ...program, ...args];
|
|
272
|
+
}
|
|
273
|
+
|
|
201
274
|
// A process another user owns still runs, so only a missing process counts
|
|
202
275
|
// as gone.
|
|
203
276
|
function isProcessRunning(pid: number): boolean {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { CString, FFIType, dlopen, read } from 'bun:ffi';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The names in the daemon process's native environment: the block a
|
|
6
|
+
* native library reads when it starts a child, as opposed to the copy Bun
|
|
7
|
+
* keeps for `process.env`. Changes to `process.env` never reach that
|
|
8
|
+
* block, so it keeps every variable the daemon started with, including
|
|
9
|
+
* ones deleted from `process.env` since.
|
|
10
|
+
*
|
|
11
|
+
* Linux reads the block from `/proc/self/environ`; macOS walks the live
|
|
12
|
+
* `environ` array that `_NSGetEnviron` returns. Any other platform throws,
|
|
13
|
+
* since a harness started without the list could inherit a withheld
|
|
14
|
+
* variable.
|
|
15
|
+
*/
|
|
16
|
+
export function readNativeEnvKeys(): string[] {
|
|
17
|
+
const keys = new Set<string>();
|
|
18
|
+
|
|
19
|
+
for (const entry of readNativeEnvEntries()) {
|
|
20
|
+
const eq = entry.indexOf('=');
|
|
21
|
+
|
|
22
|
+
if (eq > 0) {
|
|
23
|
+
keys.add(entry.slice(0, eq));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...keys];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readNativeEnvEntries(): string[] {
|
|
31
|
+
if (process.platform === 'linux') {
|
|
32
|
+
return readFileSync('/proc/self/environ', 'utf8').split('\0');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (process.platform === 'darwin') {
|
|
36
|
+
return readDarwinEnviron();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(`atc cannot read the native environment on ${process.platform}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const POINTER_BYTES = 8;
|
|
43
|
+
|
|
44
|
+
// `_NSGetEnviron` returns a pointer to `environ`, a NULL-terminated array of
|
|
45
|
+
// `NAME=value` C strings.
|
|
46
|
+
function readDarwinEnviron(): string[] {
|
|
47
|
+
const libSystem = dlopen('/usr/lib/libSystem.B.dylib', {
|
|
48
|
+
_NSGetEnviron: { args: [], returns: FFIType.ptr },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// oxlint-disable-next-line no-underscore-dangle -- the libSystem symbol carries the underscore
|
|
53
|
+
const environ = libSystem.symbols._NSGetEnviron();
|
|
54
|
+
|
|
55
|
+
if (environ === null) {
|
|
56
|
+
throw new Error('atc cannot read the native environment: _NSGetEnviron returned NULL');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const array = read.ptr(environ);
|
|
60
|
+
const entries: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (let offset = 0; ; offset += POINTER_BYTES) {
|
|
63
|
+
const entry = read.ptr(array, offset);
|
|
64
|
+
|
|
65
|
+
if (entry === 0) {
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
entries.push(new CString(entry));
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
libSystem.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -11,6 +11,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
11
11
|
'message.turn': "atc_message_get's turn and answeredWith",
|
|
12
12
|
'message.wait': "atc_message_get's waitMs",
|
|
13
13
|
'session.forget': 'atc_session_forget',
|
|
14
|
+
'session.forget.preconditions': "atc_session_forget's pinned and live checks in the daemon",
|
|
14
15
|
'session.locator': "a session's locator",
|
|
15
16
|
'session.submit': 'atc_session_input',
|
|
16
17
|
'spawn.idempotency': "atc_session_spawn's idempotencyKey",
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -147,18 +147,26 @@ export function runTool(
|
|
|
147
147
|
return { text: 'killed', structured: null };
|
|
148
148
|
})
|
|
149
149
|
.with('atc_session_forget', async () => {
|
|
150
|
-
|
|
150
|
+
const params = {
|
|
151
|
+
session: args['session'],
|
|
152
|
+
...(typeof args['confirmToken'] === 'string' ? { confirmToken: args['confirmToken'] } : {}),
|
|
153
|
+
};
|
|
151
154
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
const stops = args['stop'] === true;
|
|
156
|
+
|
|
157
|
+
const features = await caller.readFeatures();
|
|
158
|
+
|
|
159
|
+
if (features.has('session.forget.preconditions')) {
|
|
160
|
+
const ok = await trySendGuardedForget(caller, params, stops);
|
|
161
|
+
|
|
162
|
+
if (ok !== null) {
|
|
163
|
+
return buildObjectResult(ok);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await requireForgettable(caller, args['session'], stops);
|
|
168
|
+
|
|
169
|
+
const ok = await caller.sendRequest('session.forget', params, ['session.forget']);
|
|
162
170
|
|
|
163
171
|
return buildObjectResult(ok);
|
|
164
172
|
})
|
|
@@ -285,6 +293,55 @@ function buildObjectResult(value: unknown): ToolResult {
|
|
|
285
293
|
};
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// The refusals of a forget, worded for an MCP client.
|
|
297
|
+
const SESSION_PINNED_REFUSAL =
|
|
298
|
+
'session_pinned: the session is pinned, or is a sub-session of a pinned session. Unpin it with atc_session_update before forgetting it.';
|
|
299
|
+
|
|
300
|
+
const SESSION_LIVE_REFUSAL =
|
|
301
|
+
'session_live: the session is live. Pass stop: true to stop and forget it.';
|
|
302
|
+
|
|
303
|
+
// The daemon checks pinned and live in the same step that forgets, so a pin
|
|
304
|
+
// or revive that lands just before the forget still refuses it. Null when
|
|
305
|
+
// the daemon a gateway routes the forget to lacks the checks; the caller
|
|
306
|
+
// then checks the session itself. A refusal keeps the wording of the
|
|
307
|
+
// caller's own check.
|
|
308
|
+
async function trySendGuardedForget(
|
|
309
|
+
caller: FleetCaller,
|
|
310
|
+
params: Readonly<Record<string, unknown>>,
|
|
311
|
+
stops: boolean,
|
|
312
|
+
): Promise<Readonly<Record<string, unknown>> | null> {
|
|
313
|
+
try {
|
|
314
|
+
return await caller.sendRequest(
|
|
315
|
+
'session.forget',
|
|
316
|
+
{ ...params, refusePinned: true, refuseLive: !stops },
|
|
317
|
+
['session.forget', 'session.forget.preconditions'],
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (!(error instanceof Error) || !('code' in error)) {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
error.code === 'daemon_outdated' &&
|
|
326
|
+
'data' in error &&
|
|
327
|
+
isRecord(error.data) &&
|
|
328
|
+
error.data['feature'] === 'session.forget.preconditions'
|
|
329
|
+
) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (error.code === 'session_pinned') {
|
|
334
|
+
throw new Error(SESSION_PINNED_REFUSAL, { cause: error });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (error.code === 'session_live') {
|
|
338
|
+
throw new Error(SESSION_LIVE_REFUSAL, { cause: error });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
288
345
|
// A forget is for good, so it reads the session first: an unseen or unknown
|
|
289
346
|
// session fails here before the daemon mints a token, and a pinned or live
|
|
290
347
|
// session is refused with the step that unblocks it.
|
|
@@ -306,13 +363,11 @@ async function requireForgettable(
|
|
|
306
363
|
}
|
|
307
364
|
|
|
308
365
|
if (pinned) {
|
|
309
|
-
throw new Error(
|
|
310
|
-
'session_pinned: the session is pinned, or is a sub-session of a pinned session. Unpin it with atc_session_update before forgetting it.',
|
|
311
|
-
);
|
|
366
|
+
throw new Error(SESSION_PINNED_REFUSAL);
|
|
312
367
|
}
|
|
313
368
|
|
|
314
369
|
if (descriptor['alive'] === true && !stops) {
|
|
315
|
-
throw new Error(
|
|
370
|
+
throw new Error(SESSION_LIVE_REFUSAL);
|
|
316
371
|
}
|
|
317
372
|
}
|
|
318
373
|
|
|
@@ -59,6 +59,11 @@ export const DAEMON_FEATURES = [
|
|
|
59
59
|
// can destroy its host answers `confirmation_required`.
|
|
60
60
|
'session.forget',
|
|
61
61
|
|
|
62
|
+
// `session.forget` takes `refusePinned` and `refuseLive`, checked in the
|
|
63
|
+
// same step that forgets, and refuses with `session_pinned` or
|
|
64
|
+
// `session_live`.
|
|
65
|
+
'session.forget.preconditions',
|
|
66
|
+
|
|
62
67
|
// `session.submit` exists.
|
|
63
68
|
'session.submit',
|
|
64
69
|
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -213,6 +213,12 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
213
213
|
.string({ error: 'session.forget confirmToken must be a string' })
|
|
214
214
|
.min(1, 'session.forget confirmToken must not be empty')
|
|
215
215
|
.optional(),
|
|
216
|
+
|
|
217
|
+
// Refuse a pinned session, or a sub-session of a pinned one.
|
|
218
|
+
refusePinned: z.boolean({ error: 'session.forget refusePinned must be a boolean' }).optional(),
|
|
219
|
+
|
|
220
|
+
// Refuse a live session.
|
|
221
|
+
refuseLive: z.boolean({ error: 'session.forget refuseLive must be a boolean' }).optional(),
|
|
216
222
|
}),
|
|
217
223
|
|
|
218
224
|
// Owner-only: withdraw the grants of the runtime auth binding on the
|