@zgeoff/atc 2.14.0 → 2.15.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/client/daemon-client.ts +5 -22
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/daemon-connection.ts +5 -16
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +18 -3
- package/src/mcp-server.ts +3 -14
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +4 -1
- package/src/store/state-store.ts +75 -15
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
3
4
|
import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
|
|
4
5
|
import type { EventMsg, ResponseMsg } from '../protocol/protocol';
|
|
@@ -20,9 +21,7 @@ export class DaemonClient {
|
|
|
20
21
|
|
|
21
22
|
private queue: OutboundQueue | null = null;
|
|
22
23
|
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
private readonly decoder = new TextDecoder();
|
|
24
|
+
private readonly lines = new LineDecoder();
|
|
26
25
|
|
|
27
26
|
private nextID = 1;
|
|
28
27
|
|
|
@@ -41,7 +40,7 @@ export class DaemonClient {
|
|
|
41
40
|
unix: socketPath,
|
|
42
41
|
socket: {
|
|
43
42
|
data(_s, buf) {
|
|
44
|
-
client.applyChunk(
|
|
43
|
+
client.applyChunk(buf);
|
|
45
44
|
},
|
|
46
45
|
drain() {
|
|
47
46
|
client.queue?.drain();
|
|
@@ -99,25 +98,9 @@ export class DaemonClient {
|
|
|
99
98
|
this.drainPending('client closed');
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
// Decodes with state kept across reads, so a multi-byte character split
|
|
103
|
-
// between two reads decodes whole.
|
|
104
101
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private applyChunk(chunk: string): void {
|
|
110
|
-
this.buffer += chunk;
|
|
111
|
-
|
|
112
|
-
const lines = this.buffer.split('\n');
|
|
113
|
-
|
|
114
|
-
this.buffer = lines.pop() ?? '';
|
|
115
|
-
|
|
116
|
-
for (const line of lines) {
|
|
117
|
-
if (line.trim() === '') {
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
102
|
+
private applyChunk(buf: Uint8Array): void {
|
|
103
|
+
for (const line of this.lines.splitChunk(buf)) {
|
|
121
104
|
this.applyLine(line);
|
|
122
105
|
}
|
|
123
106
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ImpClientPort } from './imp-client-port';
|
|
2
2
|
import { ImpProvider } from './imp-provider';
|
|
3
|
+
import { readImpToken } from './read-imp-token';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The provider of an `imp` target, or the problem that keeps it from one.
|
|
6
|
-
* A problem never includes
|
|
7
|
+
* A problem never includes a token or any other environment value.
|
|
7
8
|
*/
|
|
8
9
|
export interface ImpProviderBuild {
|
|
9
10
|
readonly provider: ImpProvider | null;
|
|
@@ -12,13 +13,16 @@ export interface ImpProviderBuild {
|
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The provider of target `id` from its `imp` options: `url` is where impd
|
|
15
|
-
* listens, and `tokenEnv`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* listens, and at most one of `tokenEnv` and `tokenFile` gives the token,
|
|
17
|
+
* so no credential sits in the config. `tokenEnv` holds the name of the
|
|
18
|
+
* daemon's environment variable that carries the token, read once here.
|
|
19
|
+
* `tokenFile` holds the path of a file that carries it, read here and again
|
|
20
|
+
* before each impd call and connection. `image`, `memoryMib`, `guestDir`,
|
|
21
|
+
* and `guestATC` pass through. No provider when `url` is missing, so the
|
|
22
|
+
* target lists and refuses every spawn. Both token options, either one not
|
|
23
|
+
* a non-empty string, an unset or empty variable, or an empty or unreadable
|
|
24
|
+
* file is a problem and leaves no provider; a target without either calls
|
|
25
|
+
* impd with no token.
|
|
22
26
|
*/
|
|
23
27
|
export function buildImpProvider(
|
|
24
28
|
id: string,
|
|
@@ -26,29 +30,18 @@ export function buildImpProvider(
|
|
|
26
30
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
27
31
|
): ImpProviderBuild {
|
|
28
32
|
const url = options['url'];
|
|
29
|
-
const tokenEnv = options['tokenEnv'];
|
|
30
33
|
|
|
31
34
|
if (typeof url !== 'string' || url === '') {
|
|
32
35
|
return { provider: null, problem: null };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
provider: null,
|
|
38
|
-
problem: `target ${JSON.stringify(id)} must give tokenEnv as a non-empty string`,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const token = tokenEnv === undefined ? null : env[tokenEnv];
|
|
38
|
+
const source = loadTokenSource(id, options, env);
|
|
43
39
|
|
|
44
|
-
if (
|
|
45
|
-
return {
|
|
46
|
-
provider: null,
|
|
47
|
-
problem: `target ${JSON.stringify(id)} reads its impd token from ${String(tokenEnv)}, which is unset or empty in the daemon's environment`,
|
|
48
|
-
};
|
|
40
|
+
if (typeof source === 'string') {
|
|
41
|
+
return { provider: null, problem: source };
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
const provider = new ImpProvider(new ImpClientPort({ url,
|
|
44
|
+
const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
|
|
52
45
|
...pickString(options, 'image'),
|
|
53
46
|
...pickString(options, 'guestDir'),
|
|
54
47
|
...pickString(options, 'guestATC'),
|
|
@@ -58,6 +51,60 @@ export function buildImpProvider(
|
|
|
58
51
|
return { provider, problem: null };
|
|
59
52
|
}
|
|
60
53
|
|
|
54
|
+
// How the port reads the token: a function, so a file token rereads.
|
|
55
|
+
interface TokenSource {
|
|
56
|
+
readonly readToken: () => string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The token source the options give, or the problem that keeps the target
|
|
60
|
+
// from one. A problem holds a variable's name or a file's path, never a
|
|
61
|
+
// token.
|
|
62
|
+
function loadTokenSource(
|
|
63
|
+
id: string,
|
|
64
|
+
options: Readonly<Record<string, unknown>>,
|
|
65
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
66
|
+
): TokenSource | string {
|
|
67
|
+
const tokenEnv = options['tokenEnv'];
|
|
68
|
+
const tokenFile = options['tokenFile'];
|
|
69
|
+
const target = JSON.stringify(id);
|
|
70
|
+
|
|
71
|
+
if (tokenEnv !== undefined && tokenFile !== undefined) {
|
|
72
|
+
return `target ${target} must give its impd token through tokenEnv or tokenFile, not both`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (tokenFile !== undefined) {
|
|
76
|
+
if (typeof tokenFile !== 'string' || tokenFile === '') {
|
|
77
|
+
return `target ${target} must give tokenFile as a non-empty string`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
readImpToken(tokenFile);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
84
|
+
|
|
85
|
+
return `target ${target} reads its impd token from a file it cannot use: ${detail}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { readToken: () => readImpToken(tokenFile) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (tokenEnv === undefined) {
|
|
92
|
+
return { readToken: () => null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof tokenEnv !== 'string' || tokenEnv === '') {
|
|
96
|
+
return `target ${target} must give tokenEnv as a non-empty string`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const token = env[tokenEnv];
|
|
100
|
+
|
|
101
|
+
if (token === undefined || token === '') {
|
|
102
|
+
return `target ${target} reads its impd token from ${tokenEnv}, which is unset or empty in the daemon's environment`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { readToken: () => token };
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
function pickString(
|
|
62
109
|
options: Readonly<Record<string, unknown>>,
|
|
63
110
|
key: 'image' | 'guestDir' | 'guestATC',
|
|
@@ -4,6 +4,7 @@ import { DaemonError } from '../protocol/daemon-error';
|
|
|
4
4
|
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
5
5
|
import { decodeCursor } from '../protocol/decode-cursor';
|
|
6
6
|
import { encodeCursor } from '../protocol/encode-cursor';
|
|
7
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
7
8
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
8
9
|
import type { SocketWriter } from '../protocol/outbound-queue';
|
|
9
10
|
import { parseRequestParams } from '../protocol/parse-request-params';
|
|
@@ -278,9 +279,7 @@ export class DaemonConnection {
|
|
|
278
279
|
|
|
279
280
|
private readonly queue: OutboundQueue;
|
|
280
281
|
|
|
281
|
-
private
|
|
282
|
-
|
|
283
|
-
private readonly decoder = new TextDecoder();
|
|
282
|
+
private readonly lines = new LineDecoder();
|
|
284
283
|
|
|
285
284
|
private helloed = false;
|
|
286
285
|
|
|
@@ -352,28 +351,18 @@ export class DaemonConnection {
|
|
|
352
351
|
// between two reads decodes whole.
|
|
353
352
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
354
353
|
decodeChunk(buf: Uint8Array): string {
|
|
355
|
-
return this.
|
|
354
|
+
return this.lines.decodeText(buf);
|
|
356
355
|
}
|
|
357
356
|
|
|
358
357
|
applyChunk(chunk: string): void {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (buffered.length > MAX_LINE) {
|
|
358
|
+
if (this.lines.pendingLength + chunk.length > MAX_LINE) {
|
|
362
359
|
this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
|
|
363
360
|
this.peer.end();
|
|
364
361
|
|
|
365
362
|
return;
|
|
366
363
|
}
|
|
367
364
|
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
this.buffer = lines.pop() ?? '';
|
|
371
|
-
|
|
372
|
-
for (const line of lines) {
|
|
373
|
-
if (line.trim() === '') {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
|
|
365
|
+
for (const line of this.lines.splitText(chunk)) {
|
|
377
366
|
if (!this.applyLine(line)) {
|
|
378
367
|
this.peer.end();
|
|
379
368
|
|
package/src/daemon/hooks.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { unlinkSync } from 'node:fs';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { socketPath } from '../shared/config';
|
|
3
4
|
import type { SessionID } from '../shared/session-id';
|
|
4
5
|
import { parseHookLine } from './parse-hook-line';
|
|
@@ -9,12 +10,9 @@ export interface HookEvent {
|
|
|
9
10
|
payload: Record<string, unknown>;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
// Per-connection read state: the
|
|
13
|
-
// and a streaming decoder so a multi-byte character split across two reads
|
|
14
|
-
// decodes whole.
|
|
13
|
+
// Per-connection read state: the connection's own line framing.
|
|
15
14
|
interface HookConnection {
|
|
16
|
-
|
|
17
|
-
readonly decoder: TextDecoder;
|
|
15
|
+
readonly lines: LineDecoder;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
|
|
@@ -26,17 +24,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
26
24
|
unix: path,
|
|
27
25
|
socket: {
|
|
28
26
|
data(socket, buf) {
|
|
29
|
-
const
|
|
30
|
-
const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
|
|
31
|
-
const lines = buffered.split('\n');
|
|
32
|
-
|
|
33
|
-
conn.pending = lines.pop() ?? '';
|
|
34
|
-
|
|
35
|
-
for (const line of lines) {
|
|
36
|
-
if (line.trim() === '') {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
27
|
+
for (const line of socket.data.lines.splitChunk(buf)) {
|
|
40
28
|
const event = parseHookLine(line);
|
|
41
29
|
|
|
42
30
|
if (event !== null) {
|
|
@@ -45,7 +33,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
45
33
|
}
|
|
46
34
|
},
|
|
47
35
|
open(socket) {
|
|
48
|
-
socket.data = {
|
|
36
|
+
socket.data = { lines: new LineDecoder() };
|
|
49
37
|
},
|
|
50
38
|
error() {},
|
|
51
39
|
},
|
|
@@ -18,12 +18,15 @@ import type {
|
|
|
18
18
|
import { ImpPortError } from './imp-port-error';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Where impd listens and the
|
|
22
|
-
*
|
|
21
|
+
* Where impd listens, and how the daemon reads the token it calls impd
|
|
22
|
+
* with: null for no token. The port reads the token again before each
|
|
23
|
+
* call and connection, so a token that changes at its source takes effect
|
|
24
|
+
* on the next one. A read that throws fails that call without reaching
|
|
25
|
+
* impd.
|
|
23
26
|
*/
|
|
24
27
|
export interface ImpClientPortOptions {
|
|
25
28
|
readonly url: string;
|
|
26
|
-
readonly
|
|
29
|
+
readonly readToken: () => string | null;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -35,30 +38,26 @@ export interface ImpClientPortOptions {
|
|
|
35
38
|
export class ImpClientPort implements ImpPort {
|
|
36
39
|
private readonly url: string;
|
|
37
40
|
|
|
38
|
-
private readonly
|
|
41
|
+
private readonly readToken: () => string | null;
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
// The client for the token read last, rebuilt when the token changes.
|
|
44
|
+
private client: Readonly<{ token: string | null; client: ImpClient }> | null = null;
|
|
41
45
|
|
|
42
46
|
constructor(options: ImpClientPortOptions) {
|
|
43
47
|
this.url = options.url;
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
this.client = createImpClient({
|
|
47
|
-
url: options.url,
|
|
48
|
-
...(options.token === null ? {} : { token: options.token }),
|
|
49
|
-
});
|
|
48
|
+
this.readToken = options.readToken;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
// An impd from before the flags has neither.
|
|
53
52
|
readonly readFeatures = async (): Promise<ImpFeatures> => {
|
|
54
|
-
const info = await this.tryCall(() =>
|
|
53
|
+
const info = await this.tryCall((client) => client.system.info());
|
|
55
54
|
|
|
56
55
|
return info.features ?? { sessionOffsets: false, leases: false };
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
readonly readImp = async (name: string): Promise<ImpView | null> => {
|
|
60
59
|
try {
|
|
61
|
-
const imp = await this.tryCall(() =>
|
|
60
|
+
const imp = await this.tryCall((client) => client.imps.get({ name }));
|
|
62
61
|
|
|
63
62
|
return {
|
|
64
63
|
name: imp.name,
|
|
@@ -76,8 +75,8 @@ export class ImpClientPort implements ImpPort {
|
|
|
76
75
|
};
|
|
77
76
|
|
|
78
77
|
readonly createImp = async (spec: ImpCreateSpec): Promise<ImpView> => {
|
|
79
|
-
const imp = await this.tryCall(() =>
|
|
80
|
-
|
|
78
|
+
const imp = await this.tryCall((client) =>
|
|
79
|
+
client.imps.create({
|
|
81
80
|
name: spec.name,
|
|
82
81
|
...(spec.image === undefined ? {} : { image: spec.image }),
|
|
83
82
|
...(spec.memoryMib === undefined ? {} : { memoryMib: spec.memoryMib }),
|
|
@@ -92,7 +91,9 @@ export class ImpClientPort implements ImpPort {
|
|
|
92
91
|
label: string,
|
|
93
92
|
ttlSeconds: number,
|
|
94
93
|
): Promise<ImpLease> => {
|
|
95
|
-
const lease = await this.tryCall(() =>
|
|
94
|
+
const lease = await this.tryCall((client) =>
|
|
95
|
+
client.leases.acquire({ name, label, ttlSeconds }),
|
|
96
|
+
);
|
|
96
97
|
|
|
97
98
|
return toLease(lease);
|
|
98
99
|
};
|
|
@@ -102,23 +103,23 @@ export class ImpClientPort implements ImpPort {
|
|
|
102
103
|
label: string,
|
|
103
104
|
ttlSeconds: number,
|
|
104
105
|
): Promise<ImpLease> => {
|
|
105
|
-
const lease = await this.tryCall(() =>
|
|
106
|
+
const lease = await this.tryCall((client) => client.leases.renew({ name, label, ttlSeconds }));
|
|
106
107
|
|
|
107
108
|
return toLease(lease);
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
readonly releaseLease = async (name: string, label: string): Promise<boolean> => {
|
|
111
|
-
const result = await this.tryCall(() =>
|
|
112
|
+
const result = await this.tryCall((client) => client.leases.release({ name, label }));
|
|
112
113
|
|
|
113
114
|
return result.released;
|
|
114
115
|
};
|
|
115
116
|
|
|
116
117
|
readonly suspendImp = async (name: string): Promise<void> => {
|
|
117
|
-
await this.tryCall(() =>
|
|
118
|
+
await this.tryCall((client) => client.imps.sleep({ name }));
|
|
118
119
|
};
|
|
119
120
|
|
|
120
121
|
readonly destroyImp = async (name: string): Promise<void> => {
|
|
121
|
-
await this.tryCall(() =>
|
|
122
|
+
await this.tryCall((client) => client.imps.destroy({ name }));
|
|
122
123
|
};
|
|
123
124
|
|
|
124
125
|
// The exec WebSocket answers only after it opens, so nothing reaches the
|
|
@@ -127,9 +128,23 @@ export class ImpClientPort implements ImpPort {
|
|
|
127
128
|
request: ImpSessionRequest,
|
|
128
129
|
handlers: ImpSessionHandlers,
|
|
129
130
|
): ImpSessionConnection => {
|
|
131
|
+
const token = this.tryReadToken();
|
|
132
|
+
|
|
133
|
+
// A token that cannot be read ends the connection the way impd's
|
|
134
|
+
// refusal of a token does, without dialing impd.
|
|
135
|
+
if (token instanceof ImpPortError) {
|
|
136
|
+
return {
|
|
137
|
+
outcome: Promise.resolve({ kind: 'unauthorized' }),
|
|
138
|
+
write: () => {},
|
|
139
|
+
resize: () => {},
|
|
140
|
+
sendSignal: () => {},
|
|
141
|
+
close: () => {},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
const session = openExecSession({
|
|
131
146
|
baseUrl: this.url,
|
|
132
|
-
token
|
|
147
|
+
token,
|
|
133
148
|
start:
|
|
134
149
|
request.kind === 'start'
|
|
135
150
|
? {
|
|
@@ -179,8 +194,8 @@ export class ImpClientPort implements ImpPort {
|
|
|
179
194
|
|
|
180
195
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
|
|
181
196
|
readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
|
|
182
|
-
const result = await this.tryCall(() =>
|
|
183
|
-
|
|
197
|
+
const result = await this.tryCall((client) =>
|
|
198
|
+
client.run(name, command.argv, {
|
|
184
199
|
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
185
200
|
...(command.stdin === undefined ? {} : { stdin: command.stdin }),
|
|
186
201
|
}),
|
|
@@ -194,12 +209,30 @@ export class ImpClientPort implements ImpPort {
|
|
|
194
209
|
guestPath: string,
|
|
195
210
|
onConnection: (connection: ImpRelayConnection) => void,
|
|
196
211
|
): ImpReverseForward => {
|
|
212
|
+
const token = this.tryReadToken();
|
|
213
|
+
|
|
214
|
+
if (token instanceof ImpPortError) {
|
|
215
|
+
return { listening: Promise.reject(token), stop: () => {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
const forward = openReverseForward({
|
|
198
219
|
baseUrl: this.url,
|
|
199
|
-
token
|
|
220
|
+
token,
|
|
200
221
|
name,
|
|
201
222
|
guest: { network: 'unix', path: guestPath },
|
|
202
|
-
|
|
223
|
+
|
|
224
|
+
// The client reuses the headers of the listening socket for every
|
|
225
|
+
// guest connection relay, so each socket reads the token afresh. A
|
|
226
|
+
// token that cannot be read dials without one, which impd refuses.
|
|
227
|
+
connect: (url, headers) => {
|
|
228
|
+
const { authorization: _, ...rest } = headers;
|
|
229
|
+
const current = this.tryReadToken();
|
|
230
|
+
|
|
231
|
+
return new WebSocket(url, {
|
|
232
|
+
headers:
|
|
233
|
+
typeof current === 'string' ? { ...rest, authorization: `Bearer ${current}` } : rest,
|
|
234
|
+
});
|
|
235
|
+
},
|
|
203
236
|
onConnection: (accept) => {
|
|
204
237
|
onConnection(openRelay(accept));
|
|
205
238
|
},
|
|
@@ -215,13 +248,36 @@ export class ImpClientPort implements ImpPort {
|
|
|
215
248
|
|
|
216
249
|
// Runs one client call, turning impd's refusals and transport failures
|
|
217
250
|
// into port errors.
|
|
218
|
-
private async tryCall<T>(call: () => Promise<T>): Promise<T> {
|
|
251
|
+
private async tryCall<T>(call: (client: ImpClient) => Promise<T>): Promise<T> {
|
|
219
252
|
try {
|
|
220
|
-
return await call();
|
|
253
|
+
return await call(this.loadClient());
|
|
221
254
|
} catch (error) {
|
|
222
255
|
throw toPortError(error);
|
|
223
256
|
}
|
|
224
257
|
}
|
|
258
|
+
|
|
259
|
+
// The token as it reads now, or the port error its read throws.
|
|
260
|
+
private tryReadToken(): string | null | ImpPortError {
|
|
261
|
+
try {
|
|
262
|
+
return this.readToken();
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return toPortError(error);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The client that calls impd with the token as it reads now.
|
|
269
|
+
private loadClient(): ImpClient {
|
|
270
|
+
const token = this.readToken();
|
|
271
|
+
|
|
272
|
+
if (this.client === null || this.client.token !== token) {
|
|
273
|
+
this.client = {
|
|
274
|
+
token,
|
|
275
|
+
client: createImpClient({ url: this.url, ...(token === null ? {} : { token }) }),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return this.client.client;
|
|
280
|
+
}
|
|
225
281
|
}
|
|
226
282
|
|
|
227
283
|
// A lease as the client returns it.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
3
4
|
import { buildTarArchive } from './build-tar-archive';
|
|
4
5
|
import type {
|
|
@@ -523,22 +524,15 @@ function buildSocketName(sessionID: string): string {
|
|
|
523
524
|
|
|
524
525
|
// Frames a guest connection's bytes into lines, both ways.
|
|
525
526
|
function toHarnessRelay(connection: ImpRelayConnection): HarnessRelay {
|
|
526
|
-
const decoder = new
|
|
527
|
+
const decoder = new LineDecoder();
|
|
527
528
|
const encoder = new TextEncoder();
|
|
528
529
|
|
|
529
530
|
const lineListeners: ((line: string) => void)[] = [];
|
|
530
|
-
let pending = '';
|
|
531
531
|
|
|
532
532
|
connection.onData((data) => {
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
for (const line of lines) {
|
|
538
|
-
if (line.trim() !== '') {
|
|
539
|
-
for (const listener of lineListeners) {
|
|
540
|
-
listener(line);
|
|
541
|
-
}
|
|
533
|
+
for (const line of decoder.splitChunk(data)) {
|
|
534
|
+
for (const listener of lineListeners) {
|
|
535
|
+
listener(line);
|
|
542
536
|
}
|
|
543
537
|
}
|
|
544
538
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ImpPortError } from './imp-port-error';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The impd token held in the file at `path`, without one trailing newline.
|
|
6
|
+
* An empty or unreadable file throws an `UNAUTHORIZED` port error whose
|
|
7
|
+
* message holds the path and the read's error code, never file content.
|
|
8
|
+
*/
|
|
9
|
+
export function readImpToken(path: string): string {
|
|
10
|
+
let text: string;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
text = readFileSync(path, 'utf8');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const code =
|
|
16
|
+
error instanceof Error && 'code' in error && typeof error.code === 'string'
|
|
17
|
+
? error.code
|
|
18
|
+
: 'unknown error';
|
|
19
|
+
|
|
20
|
+
throw new ImpPortError('UNAUTHORIZED', `cannot read the impd token file ${path}: ${code}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const token = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
24
|
+
|
|
25
|
+
if (token === '') {
|
|
26
|
+
throw new ImpPortError('UNAUTHORIZED', `the impd token file ${path} is empty`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return token;
|
|
30
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -232,6 +232,10 @@ export class SessionManager {
|
|
|
232
232
|
// of the same session does not start a second harness.
|
|
233
233
|
private readonly adopting = new Set<SessionID>();
|
|
234
234
|
|
|
235
|
+
// Sessions dropped from the list on purpose whose rows the next fleet
|
|
236
|
+
// write deletes; each stays here until a write carrying it lands.
|
|
237
|
+
private readonly removedIDs = new Set<SessionID>();
|
|
238
|
+
|
|
235
239
|
// The epoch the next harness start or attach takes, unique across every
|
|
236
240
|
// session this manager holds.
|
|
237
241
|
private nextBridgeEpoch = 1;
|
|
@@ -1350,6 +1354,8 @@ export class SessionManager {
|
|
|
1350
1354
|
private remove(s: Session) {
|
|
1351
1355
|
this.sessions = this.sessions.filter((x) => x.id !== s.id);
|
|
1352
1356
|
|
|
1357
|
+
this.removedIDs.add(s.id);
|
|
1358
|
+
|
|
1353
1359
|
if (this.focusedId === s.id) {
|
|
1354
1360
|
this.focusedId = null;
|
|
1355
1361
|
}
|
|
@@ -1383,8 +1389,11 @@ export class SessionManager {
|
|
|
1383
1389
|
}
|
|
1384
1390
|
|
|
1385
1391
|
// Persisted continuously so a crash (or quit) leaves a restorable fleet.
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1392
|
+
// A write covers the listed sessions and the ones dropped on purpose
|
|
1393
|
+
// since the last write; a stored row the daemon has not restored yet
|
|
1394
|
+
// stays as it is, so a write before `R` keeps it restorable. Deliberate
|
|
1395
|
+
// kills rewrite the fleet; unexpected session/atc deaths do not, so the
|
|
1396
|
+
// last known fleet survives for `R` restore. Sessions whose
|
|
1388
1397
|
// terminal is gone persist as exited entries, so the killed archive
|
|
1389
1398
|
// survives a daemon restart too. A session the agent has not yet given
|
|
1390
1399
|
// its own session id persists as well, under its atc session id alone.
|
|
@@ -1416,7 +1425,13 @@ export class SessionManager {
|
|
|
1416
1425
|
});
|
|
1417
1426
|
}
|
|
1418
1427
|
|
|
1419
|
-
|
|
1428
|
+
const removed = [...this.removedIDs];
|
|
1429
|
+
|
|
1430
|
+
await this.store.writeFleet(fleet, removed);
|
|
1431
|
+
|
|
1432
|
+
for (const id of removed) {
|
|
1433
|
+
this.removedIDs.delete(id);
|
|
1434
|
+
}
|
|
1420
1435
|
}
|
|
1421
1436
|
|
|
1422
1437
|
// A write nobody awaits that fails leaves the stored rows as they were,
|
package/src/mcp-server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { bootDaemonClient } from './client/boot-daemon';
|
|
|
2
2
|
import { answerRPCRequest } from './mcp/answer-rpc-request';
|
|
3
3
|
import { requireDaemonFeatures } from './mcp/require-daemon-features';
|
|
4
4
|
import type { FleetCaller, ToolContext } from './mcp/types';
|
|
5
|
+
import { LineDecoder } from './protocol/line-decoder';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* An MCP server over stdio bridging to the atc daemon: any MCP client —
|
|
@@ -34,26 +35,14 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
34
35
|
sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
const decoder = new
|
|
38
|
-
|
|
39
|
-
let buffer = '';
|
|
38
|
+
const decoder = new LineDecoder();
|
|
40
39
|
|
|
41
40
|
// Each request runs on its own, so a long poll never holds up the others;
|
|
42
41
|
// responses carry their request's id.
|
|
43
42
|
const inFlight = new Set<Promise<void>>();
|
|
44
43
|
|
|
45
44
|
for await (const chunk of Bun.stdin.stream()) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const lines = buffer.split('\n');
|
|
49
|
-
|
|
50
|
-
buffer = lines.pop() ?? '';
|
|
51
|
-
|
|
52
|
-
for (const line of lines) {
|
|
53
|
-
if (line.trim() === '') {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
45
|
+
for (const line of decoder.splitChunk(chunk)) {
|
|
57
46
|
const finished = Promise.withResolvers<void>();
|
|
58
47
|
|
|
59
48
|
inFlight.add(finished.promise);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits one stream of newline-framed text into whole lines. Bytes decode
|
|
3
|
+
* as UTF-8 with state kept across reads, so a multi-byte character split
|
|
4
|
+
* between two reads decodes whole. The unterminated tail waits for the read
|
|
5
|
+
* that ends it, and a line holding only whitespace is dropped. A carriage
|
|
6
|
+
* return before the newline stays on the line.
|
|
7
|
+
*/
|
|
8
|
+
export class LineDecoder {
|
|
9
|
+
private readonly decoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
private pending = '';
|
|
12
|
+
|
|
13
|
+
get pendingLength(): number {
|
|
14
|
+
return this.pending.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
18
|
+
splitChunk(bytes: Uint8Array): string[] {
|
|
19
|
+
return this.splitText(this.decodeText(bytes));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
23
|
+
decodeText(bytes: Uint8Array): string {
|
|
24
|
+
return this.decoder.decode(bytes, { stream: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
splitText(text: string): string[] {
|
|
28
|
+
const lines = `${this.pending}${text}`.split('\n');
|
|
29
|
+
|
|
30
|
+
this.pending = lines.pop() ?? '';
|
|
31
|
+
|
|
32
|
+
return lines.filter((line) => line.trim() !== '');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LineDecoder } from './line-decoder';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* One line-framed connection to a session bridge, from inside a remote
|
|
3
5
|
* session: each line the bridge writes arrives whole, and each line written
|
|
@@ -20,10 +22,9 @@ export async function openBridgeSocket(
|
|
|
20
22
|
onLine: (line: Readonly<Record<string, unknown>>) => void,
|
|
21
23
|
): Promise<BridgeSocket> {
|
|
22
24
|
const encoder = new TextEncoder();
|
|
23
|
-
const decoder = new
|
|
25
|
+
const decoder = new LineDecoder();
|
|
24
26
|
|
|
25
27
|
const closed = Promise.withResolvers<void>();
|
|
26
|
-
let pending = '';
|
|
27
28
|
|
|
28
29
|
let unsent = new Uint8Array(0);
|
|
29
30
|
|
|
@@ -31,11 +32,7 @@ export async function openBridgeSocket(
|
|
|
31
32
|
unix: path,
|
|
32
33
|
socket: {
|
|
33
34
|
data(_socket, buf) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
pending = lines.pop() ?? '';
|
|
37
|
-
|
|
38
|
-
for (const line of lines) {
|
|
35
|
+
for (const line of decoder.splitChunk(buf)) {
|
|
39
36
|
const parsed = parseLine(line);
|
|
40
37
|
|
|
41
38
|
if (parsed !== null) {
|
package/src/report.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { sendBridgeRequest } from './protocol/send-bridge-request';
|
|
4
5
|
import { sendReport } from './shared/report';
|
|
5
6
|
import { REPORT_KINDS } from './shared/report-kinds';
|
|
6
|
-
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
7
7
|
|
|
8
8
|
interface ReportOptions {
|
|
9
9
|
readonly message: string;
|
package/src/run-bridge-tap.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { openBridgeSocket } from './
|
|
5
|
-
import type { BridgeSocket } from './
|
|
4
|
+
import { openBridgeSocket } from './protocol/open-bridge-socket';
|
|
5
|
+
import type { BridgeSocket } from './protocol/open-bridge-socket';
|
|
6
6
|
|
|
7
7
|
const INBOX_MESSAGE_SCHEMA = z.looseObject({
|
|
8
8
|
ev: z.literal('InboxMessage'),
|
package/src/statusline.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { sendBridgeRequest } from './protocol/send-bridge-request';
|
|
4
5
|
import { statusFile } from './shared/config';
|
|
5
6
|
import { isRecord, sendReport } from './shared/report';
|
|
6
|
-
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -72,7 +72,10 @@ export interface FleetEntry {
|
|
|
72
72
|
export interface FleetStore {
|
|
73
73
|
readonly daemonID: DaemonID;
|
|
74
74
|
readonly loadFleet: () => Promise<FleetEntry[]>;
|
|
75
|
-
readonly writeFleet: (
|
|
75
|
+
readonly writeFleet: (
|
|
76
|
+
entries: readonly FleetEntry[],
|
|
77
|
+
removed?: readonly SessionID[],
|
|
78
|
+
) => Promise<void>;
|
|
76
79
|
readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
|
|
77
80
|
}
|
|
78
81
|
|
package/src/store/state-store.ts
CHANGED
|
@@ -241,12 +241,22 @@ export class StateStore {
|
|
|
241
241
|
return recency;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
244
|
+
// Rewrites this daemon's rows for the given entries and drops its rows
|
|
245
|
+
// for the removed sessions, plus any row of its own that an entry
|
|
246
|
+
// replaces by holding the same agent session id. Every other row stays as
|
|
247
|
+
// it stands, so a row the daemon never loaded survives a write. A session
|
|
248
|
+
// another daemon owns, or one whose stored ownership epoch has moved past
|
|
249
|
+
// this daemon's, rejects the whole write with stale_epoch.
|
|
250
|
+
async writeFleet(
|
|
251
|
+
entries: readonly FleetEntry[],
|
|
252
|
+
removed: readonly SessionID[] = [],
|
|
253
|
+
): Promise<void> {
|
|
249
254
|
const kept = buildFleetWithoutReplacedRows(entries);
|
|
255
|
+
const sessionIDs = [...entries.map((entry) => entry.sessionID), ...removed];
|
|
256
|
+
|
|
257
|
+
const agentSessionIDs = entries.flatMap((entry) =>
|
|
258
|
+
entry.agentSessionID === undefined ? [] : [entry.agentSessionID],
|
|
259
|
+
);
|
|
250
260
|
|
|
251
261
|
await this.db.transaction().execute(async (trx) => {
|
|
252
262
|
await requireCurrentOwnership(
|
|
@@ -255,17 +265,35 @@ export class StateStore {
|
|
|
255
265
|
entries.map((entry) => entry.sessionID),
|
|
256
266
|
);
|
|
257
267
|
|
|
258
|
-
|
|
259
|
-
.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.execute();
|
|
268
|
+
const replaced =
|
|
269
|
+
agentSessionIDs.length === 0
|
|
270
|
+
? []
|
|
271
|
+
: await trx
|
|
272
|
+
.selectFrom('fleet')
|
|
273
|
+
.select(['session_id', 'agent_session_id'])
|
|
274
|
+
.where('agent_session_id', 'in', agentSessionIDs)
|
|
275
|
+
.execute();
|
|
267
276
|
|
|
268
|
-
|
|
277
|
+
const dropped = [...new Set([...sessionIDs, ...replaced.map((row) => row.session_id)])];
|
|
278
|
+
|
|
279
|
+
if (dropped.length > 0) {
|
|
280
|
+
await trx
|
|
281
|
+
.deleteFrom('fleet')
|
|
282
|
+
.where('session_id', 'in', dropped)
|
|
283
|
+
.where('session_id', 'in', (eb) =>
|
|
284
|
+
eb
|
|
285
|
+
.selectFrom('session_owner')
|
|
286
|
+
.select('session_id')
|
|
287
|
+
.where('daemon_id', '=', this.daemonID),
|
|
288
|
+
)
|
|
289
|
+
.execute();
|
|
290
|
+
|
|
291
|
+
await trx
|
|
292
|
+
.deleteFrom('session_owner')
|
|
293
|
+
.where('daemon_id', '=', this.daemonID)
|
|
294
|
+
.where('session_id', 'in', dropped)
|
|
295
|
+
.execute();
|
|
296
|
+
}
|
|
269
297
|
|
|
270
298
|
const updatedAt = Date.now();
|
|
271
299
|
|
|
@@ -304,6 +332,38 @@ export class StateStore {
|
|
|
304
332
|
})
|
|
305
333
|
.execute();
|
|
306
334
|
}
|
|
335
|
+
|
|
336
|
+
// A stored row this write leaves in place follows its replaced parent
|
|
337
|
+
// to the session that replaced it, or to that session's own parent
|
|
338
|
+
// when the replacement is itself a sub-session.
|
|
339
|
+
const survivors = new Map(
|
|
340
|
+
kept.flatMap((entry) =>
|
|
341
|
+
entry.agentSessionID === undefined ? [] : [[entry.agentSessionID, entry] as const],
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
for (const row of replaced) {
|
|
346
|
+
const survivor =
|
|
347
|
+
row.agent_session_id === null
|
|
348
|
+
? undefined
|
|
349
|
+
: survivors.get(toAgentSessionID(row.agent_session_id));
|
|
350
|
+
|
|
351
|
+
if (survivor === undefined || survivor.sessionID === row.session_id) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await trx
|
|
356
|
+
.updateTable('fleet')
|
|
357
|
+
.set({ parent_session_id: survivor.parent ?? survivor.sessionID })
|
|
358
|
+
.where('parent_session_id', '=', row.session_id)
|
|
359
|
+
.where('session_id', 'in', (eb) =>
|
|
360
|
+
eb
|
|
361
|
+
.selectFrom('session_owner')
|
|
362
|
+
.select('session_id')
|
|
363
|
+
.where('daemon_id', '=', this.daemonID),
|
|
364
|
+
)
|
|
365
|
+
.execute();
|
|
366
|
+
}
|
|
307
367
|
});
|
|
308
368
|
}
|
|
309
369
|
|
|
File without changes
|