@zgeoff/atc 2.27.0 → 2.29.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 +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +54 -8
- package/src/client/plan-overlay-columns.ts +155 -0
- package/src/client/resolve-agent-metadata.ts +75 -0
- package/src/client/spawn-picker.ts +194 -41
- package/src/client/split-to-width.ts +47 -0
- package/src/client/to-mirror-session.ts +44 -0
- package/src/client/ui.ts +118 -28
- package/src/daemon/{make-non-blocking-log.ts → create-non-blocking-log.ts} +66 -8
- package/src/daemon/daemon-connection.ts +25 -12
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/materialize-workspace.ts +1 -1
- package/src/daemon/request-access-classes.ts +54 -0
- package/src/daemon/sessions.ts +209 -47
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/protocol/request-param-schemas.ts +3 -2
- package/src/workspace/resolve-path-source.ts +21 -6
- package/src/client/format-overlay-agent-mark.ts +0 -15
package/src/client/ui.ts
CHANGED
|
@@ -3,9 +3,11 @@ import type { SessionState } from '../protocol/session-state';
|
|
|
3
3
|
import type { AgentID } from '../shared/agent-id';
|
|
4
4
|
import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
|
|
5
5
|
import { formatDir } from './dirs';
|
|
6
|
-
import {
|
|
6
|
+
import { planOverlayColumns } from './plan-overlay-columns';
|
|
7
|
+
import type { OverlayColumnPlan } from './plan-overlay-columns';
|
|
7
8
|
import { planVacatedRows } from './plan-vacated-rows';
|
|
8
9
|
import type { BoxExtent } from './plan-vacated-rows';
|
|
10
|
+
import type { HarnessLifecycle } from './to-mirror-session';
|
|
9
11
|
|
|
10
12
|
// The slice of a session the drawing layer needs; satisfied by both the
|
|
11
13
|
// daemon's sessions and the wire descriptors a client mirrors.
|
|
@@ -39,13 +41,6 @@ const GLYPH: Record<SessionState, string> = {
|
|
|
39
41
|
exited: `${ESC}[90m✗${ESC}[0m`,
|
|
40
42
|
};
|
|
41
43
|
|
|
42
|
-
const STATE_LABEL: Record<SessionState, string> = {
|
|
43
|
-
needs_you: 'NEEDS YOU',
|
|
44
|
-
running: 'running',
|
|
45
|
-
done: 'done',
|
|
46
|
-
exited: 'exited',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
44
|
function out(s: string) {
|
|
50
45
|
process.stdout.write(s);
|
|
51
46
|
}
|
|
@@ -65,6 +60,14 @@ export function cols(): number {
|
|
|
65
60
|
return process.stdout.columns || 80;
|
|
66
61
|
}
|
|
67
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The width of the picker box, borders included. An item row holds 4
|
|
65
|
+
* columns fewer.
|
|
66
|
+
*/
|
|
67
|
+
export function getPickerWidth(): number {
|
|
68
|
+
return Math.min(cols() - 4, 90);
|
|
69
|
+
}
|
|
70
|
+
|
|
68
71
|
export function rows(): number {
|
|
69
72
|
return process.stdout.rows || 24;
|
|
70
73
|
}
|
|
@@ -186,14 +189,25 @@ export interface OverlaySessionView extends SessionView {
|
|
|
186
189
|
readonly agent: AgentID;
|
|
187
190
|
readonly pinned: boolean;
|
|
188
191
|
readonly repoRoot: string;
|
|
192
|
+
readonly target: string;
|
|
193
|
+
readonly model: string | null;
|
|
194
|
+
|
|
195
|
+
// Where the session's harness stands, apart from the attention its state
|
|
196
|
+
// carries: running, suspended inside a sleeping host, or exited.
|
|
197
|
+
readonly harness: HarnessLifecycle;
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
export interface OverlayView {
|
|
192
201
|
sessions: readonly OverlaySessionView[];
|
|
193
202
|
|
|
194
|
-
// The
|
|
195
|
-
// the
|
|
196
|
-
|
|
203
|
+
// The readable name per agent id, and each agent's tier-alias map, from
|
|
204
|
+
// the daemon's `agents.list` answer over the config.
|
|
205
|
+
agentLabels: Readonly<Record<AgentID, string>>;
|
|
206
|
+
agentModels: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
|
|
207
|
+
|
|
208
|
+
// Whether more than one execution target is available to spawn on, which
|
|
209
|
+
// is what brings the target column in.
|
|
210
|
+
showTarget: boolean;
|
|
197
211
|
selected: number;
|
|
198
212
|
confirmKill: boolean;
|
|
199
213
|
|
|
@@ -205,6 +219,67 @@ export interface OverlayView {
|
|
|
205
219
|
grouped: boolean;
|
|
206
220
|
}
|
|
207
221
|
|
|
222
|
+
// A daemon-supplied id or alias can name an inherited property, so map
|
|
223
|
+
// reads go through an own-property check instead of indexing blindly.
|
|
224
|
+
function readMapValue<V>(map: Readonly<Record<string, V>>, key: string): V | undefined {
|
|
225
|
+
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A session's model as the row draws it: the daemon answer's alias target,
|
|
229
|
+
// or the raw model string when the answer maps nothing for it.
|
|
230
|
+
function resolveModelAlias(
|
|
231
|
+
models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>,
|
|
232
|
+
agent: AgentID,
|
|
233
|
+
model: string,
|
|
234
|
+
): string {
|
|
235
|
+
const aliases = readMapValue(models, agent);
|
|
236
|
+
|
|
237
|
+
return aliases === undefined ? model : (readMapValue(aliases, model) ?? model);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A session row draws status (the attention glyph plus the unread mark),
|
|
241
|
+
// pin, name, the flat view's directory, then the target, harness, model,
|
|
242
|
+
// harness lifecycle, and last event columns the width plan allows.
|
|
243
|
+
export function buildSessionRow(
|
|
244
|
+
s: Readonly<OverlaySessionView>,
|
|
245
|
+
plan: Readonly<OverlayColumnPlan>,
|
|
246
|
+
view: Readonly<OverlayView>,
|
|
247
|
+
sel: boolean,
|
|
248
|
+
): Row {
|
|
249
|
+
const sub = s.parent !== null && view.sessions.some((x) => x.id === s.parent);
|
|
250
|
+
|
|
251
|
+
const name =
|
|
252
|
+
plan.nameWidth >= 4 && sub
|
|
253
|
+
? `↳ ${truncate(s.name, plan.nameWidth - 2)}`.padEnd(plan.nameWidth)
|
|
254
|
+
: truncate(s.name, plan.nameWidth).padEnd(plan.nameWidth);
|
|
255
|
+
|
|
256
|
+
const dir =
|
|
257
|
+
plan.dirWidth > 0 ? truncate(formatDir(s.cwd), plan.dirWidth).padEnd(plan.dirWidth) : '';
|
|
258
|
+
|
|
259
|
+
const target =
|
|
260
|
+
plan.targetWidth > 0 ? truncate(s.target, plan.targetWidth).padEnd(plan.targetWidth) : '';
|
|
261
|
+
|
|
262
|
+
const label = readMapValue(view.agentLabels, s.agent) ?? s.agent;
|
|
263
|
+
const harness = truncate(label, plan.harnessWidth).padEnd(plan.harnessWidth);
|
|
264
|
+
const resolved = s.model === null ? '' : resolveModelAlias(view.agentModels, s.agent, s.model);
|
|
265
|
+
|
|
266
|
+
const model =
|
|
267
|
+
plan.modelWidth > 0 ? truncate(resolved, plan.modelWidth).padEnd(plan.modelWidth) : '';
|
|
268
|
+
|
|
269
|
+
const lifecycle = truncate(s.harness, plan.lifecycleWidth).padEnd(plan.lifecycleWidth);
|
|
270
|
+
const event = truncate(s.lastMsg, plan.eventWidth).padEnd(plan.eventWidth);
|
|
271
|
+
const cells = [name, dir, target, harness, model, lifecycle, event].filter((cell) => cell !== '');
|
|
272
|
+
const body = cells.join(' ');
|
|
273
|
+
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
274
|
+
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
275
|
+
const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
styled: `${GLYPH[s.state]}${unread}${pin} ${styledBody}`,
|
|
279
|
+
width: 4 + body.length,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
208
283
|
export function drawOverlay(view: OverlayView) {
|
|
209
284
|
const width = Math.min(cols() - 4, 90);
|
|
210
285
|
const rowsList: Row[] = [boxTop(width, 'sessions')];
|
|
@@ -215,6 +290,35 @@ export function drawOverlay(view: OverlayView) {
|
|
|
215
290
|
rowsList.push(dimRow(width, empty));
|
|
216
291
|
}
|
|
217
292
|
|
|
293
|
+
// The harness label and the model each row would draw, so the columns
|
|
294
|
+
// take the width of what is actually on screen.
|
|
295
|
+
const harnessMax = Math.max(
|
|
296
|
+
0,
|
|
297
|
+
...view.sessions.map((s) => (readMapValue(view.agentLabels, s.agent) ?? s.agent).length),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const modelMax = Math.max(
|
|
301
|
+
0,
|
|
302
|
+
...view.sessions.map((s) => {
|
|
303
|
+
if (s.model === null) {
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return resolveModelAlias(view.agentModels, s.agent, s.model).length;
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const targetMax = Math.max(0, ...view.sessions.map((s) => s.target.length));
|
|
312
|
+
|
|
313
|
+
const plan = planOverlayColumns({
|
|
314
|
+
innerWidth: width - 4,
|
|
315
|
+
grouped: view.grouped,
|
|
316
|
+
showTarget: view.showTarget,
|
|
317
|
+
targetMax,
|
|
318
|
+
harnessMax,
|
|
319
|
+
modelMax,
|
|
320
|
+
});
|
|
321
|
+
|
|
218
322
|
// The grouped view clusters sessions under dim repository headers, with
|
|
219
323
|
// pinned sessions leading in their own cluster; the flat view shows a
|
|
220
324
|
// directory column instead. The rows keep the sorted order either way. A
|
|
@@ -231,22 +335,9 @@ export function drawOverlay(view: OverlayView) {
|
|
|
231
335
|
rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
|
|
232
336
|
}
|
|
233
337
|
|
|
234
|
-
const
|
|
235
|
-
const name = (sub ? `↳ ${truncate(s.name, 14)}` : truncate(s.name, 16)).padEnd(16);
|
|
236
|
-
const state = STATE_LABEL[s.state].padEnd(9);
|
|
237
|
-
const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
238
|
-
const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
|
|
239
|
-
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
240
|
-
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
241
|
-
const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
|
|
242
|
-
const mark = formatOverlayAgentMark(s.agent, view.agentMarks);
|
|
243
|
-
const styledMark = mark === ' ' ? mark : `${ESC}[90m${mark}${ESC}[0m`;
|
|
244
|
-
const body = `${name} ${state}${dir} ${msg}`;
|
|
245
|
-
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
338
|
+
const row = buildSessionRow(s, plan, view, i === view.selected);
|
|
246
339
|
|
|
247
|
-
rowsList.push(
|
|
248
|
-
boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledMark}${styledBody}`, 4 + body.length),
|
|
249
|
-
);
|
|
340
|
+
rowsList.push(boxRow(width, row.styled, row.width));
|
|
250
341
|
}
|
|
251
342
|
|
|
252
343
|
rowsList.push(boxDivider(width));
|
|
@@ -363,7 +454,6 @@ export function drawHelp() {
|
|
|
363
454
|
'p pin or unpin — pinned sessions stay on top',
|
|
364
455
|
' a sub-session pins with its parent',
|
|
365
456
|
'g toggle grouping by repository',
|
|
366
|
-
' Grok rows show a dim g after the pin mark',
|
|
367
457
|
'n new session',
|
|
368
458
|
'r adopt an external session',
|
|
369
459
|
'/ filter · ↑↓/jk move · q quit',
|
|
@@ -393,7 +483,7 @@ export interface PickerView {
|
|
|
393
483
|
}
|
|
394
484
|
|
|
395
485
|
export function drawPicker(view: PickerView) {
|
|
396
|
-
const width =
|
|
486
|
+
const width = getPickerWidth();
|
|
397
487
|
const rowsList: Row[] = [boxTop(width, view.title)];
|
|
398
488
|
const shown = view.items.slice(0, 10);
|
|
399
489
|
|
|
@@ -9,7 +9,17 @@ const MAX_QUEUED_BYTES = 64 * 1024;
|
|
|
9
9
|
const writeAsync = promisify(write);
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* A log that never blocks the event loop. `log` queues one line, and
|
|
13
|
+
* `drain` resolves once every queued line is written or the given number of
|
|
14
|
+
* milliseconds has passed, whichever comes first.
|
|
15
|
+
*/
|
|
16
|
+
export interface NonBlockingLog {
|
|
17
|
+
readonly log: (line: string) => void;
|
|
18
|
+
readonly drain: (timeoutMs: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a log that writes each line to a file descriptor without ever
|
|
13
23
|
* blocking the event loop, so a reader that stops reading stalls nothing
|
|
14
24
|
* but the log. Each write goes through the asynchronous node:fs write,
|
|
15
25
|
* which Bun runs on its thread pool, one write at a time. Lines that
|
|
@@ -17,13 +27,22 @@ const writeAsync = promisify(write);
|
|
|
17
27
|
* next write after the drops starts with one `atc log dropped=N` line. A
|
|
18
28
|
* failed write drops its lines and counts them the same way, and the next
|
|
19
29
|
* line logged writes again.
|
|
30
|
+
*
|
|
31
|
+
* A write to a full pipe that nobody reads never returns, and it keeps the
|
|
32
|
+
* process alive until the process calls exit. A process that shuts down
|
|
33
|
+
* drains the log with a timeout and then exits: every queued line reaches a
|
|
34
|
+
* reader that keeps reading, and an unread pipe holds the exit back no
|
|
35
|
+
* longer than the timeout.
|
|
20
36
|
*/
|
|
21
|
-
export function
|
|
37
|
+
export function createNonBlockingLog(fd: number): NonBlockingLog {
|
|
22
38
|
let queue: string[] = [];
|
|
23
39
|
let queuedBytes = 0;
|
|
24
40
|
let dropped = 0;
|
|
25
41
|
let writing = false;
|
|
26
42
|
|
|
43
|
+
// The drains that wait for the log to have nothing left to write.
|
|
44
|
+
let idleWaiters: (() => void)[] = [];
|
|
45
|
+
|
|
27
46
|
const recordLine = (line: string): boolean => {
|
|
28
47
|
const text = `${line}\n`;
|
|
29
48
|
const bytes = Buffer.byteLength(text);
|
|
@@ -43,6 +62,8 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
|
43
62
|
if (queue.length === 0 && dropped === 0) {
|
|
44
63
|
writing = false;
|
|
45
64
|
|
|
65
|
+
releaseIdleWaiters();
|
|
66
|
+
|
|
46
67
|
return;
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -70,22 +91,59 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
|
70
91
|
rest = rest.subarray(result.bytesWritten);
|
|
71
92
|
}
|
|
72
93
|
} catch {
|
|
94
|
+
// A drain stops waiting here too: the next write would most likely
|
|
95
|
+
// fail the same way, and the lines it would carry stay counted.
|
|
73
96
|
dropped += lineCount;
|
|
74
97
|
writing = false;
|
|
75
98
|
|
|
99
|
+
releaseIdleWaiters();
|
|
100
|
+
|
|
76
101
|
return;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
writeNext();
|
|
80
105
|
};
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
const releaseIdleWaiters = (): void => {
|
|
108
|
+
const waiters = idleWaiters;
|
|
109
|
+
|
|
110
|
+
idleWaiters = [];
|
|
86
111
|
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
for (const resolve of waiters) {
|
|
113
|
+
resolve();
|
|
89
114
|
}
|
|
90
115
|
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
log: (line) => {
|
|
119
|
+
if (!recordLine(line)) {
|
|
120
|
+
dropped++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!writing) {
|
|
124
|
+
writeNext();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
drain: async (timeoutMs) => {
|
|
128
|
+
// Lines left behind by a failed write wait for the next line logged,
|
|
129
|
+
// so a drain writes them itself.
|
|
130
|
+
if (!writing) {
|
|
131
|
+
writeNext();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!writing) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const idle = Promise.withResolvers<void>();
|
|
139
|
+
|
|
140
|
+
idleWaiters.push(idle.resolve);
|
|
141
|
+
|
|
142
|
+
const timer = setTimeout(idle.resolve, timeoutMs);
|
|
143
|
+
|
|
144
|
+
await idle.promise;
|
|
145
|
+
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
91
149
|
}
|
|
@@ -7,6 +7,7 @@ import { LineDecoder } from '../protocol/line-decoder';
|
|
|
7
7
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
8
8
|
import type { SocketWriter } from '../protocol/outbound-queue';
|
|
9
9
|
import { parseRequestParams } from '../protocol/parse-request-params';
|
|
10
|
+
import type { RequestMethod } from '../protocol/parse-request-params';
|
|
10
11
|
import {
|
|
11
12
|
MAX_CHUNK,
|
|
12
13
|
MAX_LINE,
|
|
@@ -26,6 +27,7 @@ import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
|
26
27
|
import type { DaemonContext, SpawnParams } from './daemon-context';
|
|
27
28
|
import type { TranscriptPosition } from './load-transcript-page';
|
|
28
29
|
import { parseSpawnOverrides } from './parse-spawn-overrides';
|
|
30
|
+
import { REQUEST_ACCESS_CLASSES } from './request-access-classes';
|
|
29
31
|
import { TargetAccess } from './target-access';
|
|
30
32
|
|
|
31
33
|
// The targets a request may use and the namespace of its idempotency keys.
|
|
@@ -34,15 +36,6 @@ interface RequestScope {
|
|
|
34
36
|
readonly keyNamespace: string;
|
|
35
37
|
}
|
|
36
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
|
-
]);
|
|
45
|
-
|
|
46
39
|
// What a limited connection is sent for one event, and the sessions that
|
|
47
40
|
// event moved out of its view.
|
|
48
41
|
interface ViewUpdate {
|
|
@@ -316,7 +309,7 @@ export class DaemonConnection {
|
|
|
316
309
|
|
|
317
310
|
const scope = this.findRequestScope(req);
|
|
318
311
|
|
|
319
|
-
if (scope !== null &&
|
|
312
|
+
if (scope !== null && isOwnerOnlyMethod(req.m)) {
|
|
320
313
|
this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
|
|
321
314
|
|
|
322
315
|
return true;
|
|
@@ -379,7 +372,7 @@ export class DaemonConnection {
|
|
|
379
372
|
return;
|
|
380
373
|
}
|
|
381
374
|
|
|
382
|
-
if (
|
|
375
|
+
if (isOwnerOnlyMethod(req.m)) {
|
|
383
376
|
this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
|
|
384
377
|
|
|
385
378
|
return;
|
|
@@ -548,6 +541,12 @@ export class DaemonConnection {
|
|
|
548
541
|
private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
549
542
|
await this.helloAnswered;
|
|
550
543
|
|
|
544
|
+
if (!isRequestMethod(req.m)) {
|
|
545
|
+
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
546
|
+
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
551
550
|
switch (req.m) {
|
|
552
551
|
case 'daemon.ping': {
|
|
553
552
|
this.sendOk(req.id, {});
|
|
@@ -908,7 +907,10 @@ export class DaemonConnection {
|
|
|
908
907
|
|
|
909
908
|
return;
|
|
910
909
|
}
|
|
911
|
-
|
|
910
|
+
|
|
911
|
+
// The handshake is answered before a request is admitted, so it never
|
|
912
|
+
// runs as one.
|
|
913
|
+
case 'daemon.hello': {
|
|
912
914
|
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
913
915
|
}
|
|
914
916
|
}
|
|
@@ -1779,6 +1781,17 @@ export class DaemonConnection {
|
|
|
1779
1781
|
}
|
|
1780
1782
|
}
|
|
1781
1783
|
|
|
1784
|
+
// Whether a request method is open to the daemon's owner alone. A method
|
|
1785
|
+
// the protocol does not define is not: it runs as no request and is
|
|
1786
|
+
// answered as unknown.
|
|
1787
|
+
function isOwnerOnlyMethod(method: string): boolean {
|
|
1788
|
+
return isRequestMethod(method) && REQUEST_ACCESS_CLASSES[method] === 'owner';
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function isRequestMethod(method: string): method is RequestMethod {
|
|
1792
|
+
return Object.hasOwn(REQUEST_ACCESS_CLASSES, method);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1782
1795
|
// The token of a handshake's `auth: { scheme: "bearer", token }`, or null
|
|
1783
1796
|
// when its params hold none.
|
|
1784
1797
|
function findBearerToken(p: Readonly<Record<string, unknown>> | undefined): string | null {
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -48,6 +48,8 @@ import { buildTargetAccess } from './build-target-access';
|
|
|
48
48
|
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
49
49
|
import { buildTargetList } from './build-target-list';
|
|
50
50
|
import { claimDaemonLock } from './claim-daemon-lock';
|
|
51
|
+
import { createNonBlockingLog } from './create-non-blocking-log';
|
|
52
|
+
import type { NonBlockingLog } from './create-non-blocking-log';
|
|
51
53
|
import { DaemonConnection } from './daemon-connection';
|
|
52
54
|
import type {
|
|
53
55
|
DaemonContext,
|
|
@@ -69,7 +71,6 @@ import { loadListenerTokens } from './load-listener-tokens';
|
|
|
69
71
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
70
72
|
import { makeHookRunner } from './make-hook-runner';
|
|
71
73
|
import type { HookScope } from './make-hook-runner';
|
|
72
|
-
import { makeNonBlockingLog } from './make-non-blocking-log';
|
|
73
74
|
import { materializeWorkspace } from './materialize-workspace';
|
|
74
75
|
import { mintMessageID } from './mint-message-id';
|
|
75
76
|
import { mintSessionID } from './mint-session-id';
|
|
@@ -253,6 +254,10 @@ const MAX_REFUSAL_WINDOWS = 1024;
|
|
|
253
254
|
// Where the TCP listener logs when the daemon is given no log.
|
|
254
255
|
const STDERR_FD = 2;
|
|
255
256
|
|
|
257
|
+
// How long a stopping daemon waits for the TCP listener's log to write the
|
|
258
|
+
// lines it still holds, so an unread stderr delays the exit no longer.
|
|
259
|
+
const LOG_DRAIN_TIMEOUT_MS = 1000;
|
|
260
|
+
|
|
256
261
|
// How long startup waits for a daemon that is shutting down to release the
|
|
257
262
|
// state lock before refusing to start.
|
|
258
263
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -2082,8 +2087,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2082
2087
|
// refuses the start before any client can connect, and releases what the
|
|
2083
2088
|
// daemon holds, the lock included.
|
|
2084
2089
|
let tcpListener: TCPListener | null = null;
|
|
2090
|
+
let listenerLog: NonBlockingLog | null = null;
|
|
2085
2091
|
|
|
2086
2092
|
if (opts.listen !== undefined && listenTokens !== null) {
|
|
2093
|
+
// A given log writes as it is called, so it has nothing to drain.
|
|
2094
|
+
listenerLog =
|
|
2095
|
+
opts.log === undefined
|
|
2096
|
+
? createNonBlockingLog(STDERR_FD)
|
|
2097
|
+
: { log: opts.log, drain: () => Promise.resolve() };
|
|
2098
|
+
|
|
2087
2099
|
try {
|
|
2088
2100
|
tcpListener = startTCPListener({
|
|
2089
2101
|
host: opts.listen.host,
|
|
@@ -2099,7 +2111,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2099
2111
|
return connection;
|
|
2100
2112
|
},
|
|
2101
2113
|
closeConnection: detachConnection,
|
|
2102
|
-
log:
|
|
2114
|
+
log: listenerLog.log,
|
|
2103
2115
|
now: opts.listen.now ?? Date.now,
|
|
2104
2116
|
refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
|
|
2105
2117
|
maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
|
|
@@ -2167,6 +2179,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2167
2179
|
server.stop(true);
|
|
2168
2180
|
|
|
2169
2181
|
await releaseResources();
|
|
2182
|
+
|
|
2183
|
+
// The stopped listener logs no more lines, so this writes the ones the
|
|
2184
|
+
// log still holds, waiting no longer than the timeout when nothing reads
|
|
2185
|
+
// stderr.
|
|
2186
|
+
await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
|
|
2170
2187
|
};
|
|
2171
2188
|
|
|
2172
2189
|
writeDaemonRecord(recordPath, {
|
|
@@ -326,7 +326,7 @@ async function resolveSource(
|
|
|
326
326
|
await requireNoOriginRewriteCredentials(source.path);
|
|
327
327
|
|
|
328
328
|
const resolved = await resolvePathSource(source.path, {
|
|
329
|
-
allowDirty: source.allowDirty
|
|
329
|
+
...(source.allowDirty === undefined ? {} : { allowDirty: source.allowDirty }),
|
|
330
330
|
transports,
|
|
331
331
|
});
|
|
332
332
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RequestMethod } from '../protocol/parse-request-params';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who may make a request: `owner` is the daemon's owner alone, and
|
|
5
|
+
* `principal` is also open to a principal, which runs it through the
|
|
6
|
+
* context scoped to that principal.
|
|
7
|
+
*/
|
|
8
|
+
export type RequestAccessClass = 'owner' | 'principal';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The access class of every request method, checked when a request is
|
|
12
|
+
* admitted and before it runs. The type takes every method the protocol
|
|
13
|
+
* defines, so a method without a class fails typecheck. Owner-class
|
|
14
|
+
* methods act on the whole daemon, or on the credentials a session's host
|
|
15
|
+
* may use.
|
|
16
|
+
*/
|
|
17
|
+
export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAccessClass>> = {
|
|
18
|
+
'daemon.hello': 'principal',
|
|
19
|
+
'daemon.ping': 'principal',
|
|
20
|
+
'daemon.quit': 'owner',
|
|
21
|
+
'session.list': 'principal',
|
|
22
|
+
'dirs.list': 'principal',
|
|
23
|
+
'agents.list': 'principal',
|
|
24
|
+
'sources.list': 'principal',
|
|
25
|
+
'sources.interpret': 'principal',
|
|
26
|
+
'git.probe': 'principal',
|
|
27
|
+
'fleet.list': 'principal',
|
|
28
|
+
'fleet.restore': 'owner',
|
|
29
|
+
'session.spawn': 'principal',
|
|
30
|
+
'session.kill': 'principal',
|
|
31
|
+
'session.ack': 'principal',
|
|
32
|
+
'session.forget': 'principal',
|
|
33
|
+
'session.resumeCommand': 'principal',
|
|
34
|
+
'session.auth.revoke': 'owner',
|
|
35
|
+
'session.auth.rebind': 'owner',
|
|
36
|
+
'session.update': 'principal',
|
|
37
|
+
'session.attach': 'principal',
|
|
38
|
+
'session.detach': 'principal',
|
|
39
|
+
'session.input': 'principal',
|
|
40
|
+
'session.submit': 'principal',
|
|
41
|
+
'session.resize': 'principal',
|
|
42
|
+
'session.screen': 'principal',
|
|
43
|
+
'session.eject': 'principal',
|
|
44
|
+
'session.adopt': 'principal',
|
|
45
|
+
'permission.respond': 'principal',
|
|
46
|
+
'session.get': 'principal',
|
|
47
|
+
'session.read': 'principal',
|
|
48
|
+
'events.read': 'principal',
|
|
49
|
+
'session.message': 'principal',
|
|
50
|
+
'session.tap': 'principal',
|
|
51
|
+
'message.get': 'principal',
|
|
52
|
+
'report.get': 'principal',
|
|
53
|
+
'message.ack': 'principal',
|
|
54
|
+
};
|