@zgeoff/atc 2.14.0 → 2.15.1
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 +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +7 -23
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-scoped-context.ts +1 -1
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +9 -253
- package/src/daemon/daemon-context.ts +240 -0
- package/src/daemon/daemon.ts +9 -5
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +6 -24
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +134 -107
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/mcp-server.ts +3 -14
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +6 -3
- package/src/store/state-store.ts +80 -19
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
|
@@ -23,7 +23,9 @@ interface IdempotentCall<T> {
|
|
|
23
23
|
// effect may still stand, so the claim is kept as outcome_unknown.
|
|
24
24
|
readonly start: () => T | Promise<T>;
|
|
25
25
|
|
|
26
|
-
// Resolves once the effect is durable; the claim completes only after.
|
|
26
|
+
// Resolves once the effect is durable; the claim completes only after. A
|
|
27
|
+
// settle that throws leaves the effect standing, so the answer is
|
|
28
|
+
// outcome_unknown and the claim stays held.
|
|
27
29
|
readonly settle: () => Promise<void>;
|
|
28
30
|
|
|
29
31
|
// The answer to a retry of a completed key.
|
|
@@ -43,19 +45,27 @@ interface IdempotentCall<T> {
|
|
|
43
45
|
* answer; one with a different payload is `idempotency_conflict`; one whose
|
|
44
46
|
* effect a stopped daemon may or may not have run is `outcome_unknown` with
|
|
45
47
|
* the effect id in `data.effectRef`, and never starts the effect again.
|
|
48
|
+
* An effect that may still stand once its request fails is answered with
|
|
49
|
+
* `outcome_unknown` and its effect id even when recording that outcome
|
|
50
|
+
* fails: the claim stays held either way, and the failed write goes to the
|
|
51
|
+
* log.
|
|
46
52
|
*/
|
|
47
53
|
export class IdempotencyLedger {
|
|
48
54
|
private readonly store: StateStore;
|
|
49
55
|
|
|
50
56
|
private readonly principal: string;
|
|
51
57
|
|
|
58
|
+
// Where a failed write to the ledger is reported, one line at a time.
|
|
59
|
+
private readonly log: (line: string) => void;
|
|
60
|
+
|
|
52
61
|
// One promise chain per key, so two requests under the same key never
|
|
53
62
|
// interleave their claim and completion on one daemon.
|
|
54
63
|
private readonly locks = new Map<string, Promise<void>>();
|
|
55
64
|
|
|
56
|
-
constructor(store: StateStore, principal: string) {
|
|
65
|
+
constructor(store: StateStore, principal: string, log: (line: string) => void) {
|
|
57
66
|
this.store = store;
|
|
58
67
|
this.principal = principal;
|
|
68
|
+
this.log = log;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
|
|
@@ -114,13 +124,11 @@ export class IdempotencyLedger {
|
|
|
114
124
|
result = await call.start();
|
|
115
125
|
} catch (error) {
|
|
116
126
|
if (error instanceof EffectRemainsError) {
|
|
117
|
-
|
|
127
|
+
this.logFailure(call, 'start', error);
|
|
128
|
+
|
|
129
|
+
await this.tryRecordOutcomeUnknown(call, id);
|
|
118
130
|
|
|
119
|
-
throw
|
|
120
|
-
'outcome_unknown',
|
|
121
|
-
`the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
|
|
122
|
-
{ effectRef: call.effectRef },
|
|
123
|
-
);
|
|
131
|
+
throw buildOutcomeUnknownError(call, 'failed');
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
await this.store.removeIdempotencyKey(id);
|
|
@@ -128,20 +136,61 @@ export class IdempotencyLedger {
|
|
|
128
136
|
throw error;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
139
|
+
// From here the effect stands. A settle or completion that fails keeps
|
|
140
|
+
// the claim in progress, so a retry answers outcome_unknown rather than
|
|
141
|
+
// running the effect again.
|
|
142
|
+
try {
|
|
143
|
+
await call.settle();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.logFailure(call, 'settle', error);
|
|
146
|
+
|
|
147
|
+
await this.tryRecordOutcomeUnknown(call, id);
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
throw buildOutcomeUnknownError(call, 'could not be made durable');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await this.store.updateIdempotencyCompleted(
|
|
154
|
+
id,
|
|
155
|
+
JSON.stringify(result),
|
|
156
|
+
Date.now(),
|
|
157
|
+
call.findEffectTarget?.(result) ?? null,
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logFailure(call, 'completion write', error);
|
|
161
|
+
throw buildOutcomeUnknownError(call, 'could not be recorded as completed');
|
|
162
|
+
}
|
|
142
163
|
|
|
143
164
|
return result;
|
|
144
165
|
}
|
|
166
|
+
|
|
167
|
+
// A claim this write fails to update stays in progress, which a retry
|
|
168
|
+
// answers the same way, so the failure is logged and not thrown.
|
|
169
|
+
private async tryRecordOutcomeUnknown(
|
|
170
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
171
|
+
id: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
|
|
175
|
+
} catch (error) {
|
|
176
|
+
this.logFailure(call, 'outcome_unknown write', error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The line holds the operation, key, effect id, the step that failed, and
|
|
181
|
+
// the error, never the request's payload or the effect's result.
|
|
182
|
+
private logFailure(
|
|
183
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
184
|
+
step: string,
|
|
185
|
+
error: unknown,
|
|
186
|
+
): void {
|
|
187
|
+
const cause =
|
|
188
|
+
error instanceof Error && error.cause instanceof Error ? ` (${String(error.cause)})` : '';
|
|
189
|
+
|
|
190
|
+
this.log(
|
|
191
|
+
`atc ${call.operation} under idempotency key '${call.keyed.key}' (effect ${call.effectRef}): ${step} failed: ${String(error)}${cause}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
145
194
|
}
|
|
146
195
|
|
|
147
196
|
function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
|
|
@@ -162,3 +211,14 @@ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T |
|
|
|
162
211
|
{ effectRef: held.effectRef },
|
|
163
212
|
);
|
|
164
213
|
}
|
|
214
|
+
|
|
215
|
+
function buildOutcomeUnknownError(
|
|
216
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
217
|
+
what: string,
|
|
218
|
+
): DaemonError {
|
|
219
|
+
return new DaemonError(
|
|
220
|
+
'outcome_unknown',
|
|
221
|
+
`the ${call.operation} under idempotency key '${call.keyed.key}' ${what} and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
|
|
222
|
+
{ effectRef: call.effectRef },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -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.
|
|
@@ -99,6 +99,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
99
99
|
|
|
100
100
|
private killPending = false;
|
|
101
101
|
|
|
102
|
+
// Whether the process exited, once the harness stops being followed, and
|
|
103
|
+
// the waits on that answer until then.
|
|
104
|
+
private exitConfirmed: boolean | null = null;
|
|
105
|
+
|
|
106
|
+
private readonly exitWaiters = new Set<{
|
|
107
|
+
readonly waited: PromiseWithResolvers<boolean>;
|
|
108
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
109
|
+
}>();
|
|
110
|
+
|
|
102
111
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
112
|
|
|
104
113
|
// The terminal size the open connection's request asked for.
|
|
@@ -175,6 +184,28 @@ export class ImpHarness implements HarnessHandle {
|
|
|
175
184
|
}
|
|
176
185
|
};
|
|
177
186
|
|
|
187
|
+
// Only an exit impd reports counts: a host that lost the process or went
|
|
188
|
+
// to sleep with it inside confirms nothing.
|
|
189
|
+
readonly waitForExit = (timeoutMs: number): Promise<boolean> => {
|
|
190
|
+
if (this.exitConfirmed !== null) {
|
|
191
|
+
return Promise.resolve(this.exitConfirmed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const waited = Promise.withResolvers<boolean>();
|
|
195
|
+
|
|
196
|
+
const waiter = {
|
|
197
|
+
waited,
|
|
198
|
+
timer: setTimeout(() => {
|
|
199
|
+
this.exitWaiters.delete(waiter);
|
|
200
|
+
waited.resolve(false);
|
|
201
|
+
}, timeoutMs),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
this.exitWaiters.add(waiter);
|
|
205
|
+
|
|
206
|
+
return waited.promise;
|
|
207
|
+
};
|
|
208
|
+
|
|
178
209
|
readonly detach = (): void => {
|
|
179
210
|
if (this.done) {
|
|
180
211
|
return;
|
|
@@ -521,6 +552,8 @@ export class ImpHarness implements HarnessHandle {
|
|
|
521
552
|
private emitExit(exit: HarnessExit): void {
|
|
522
553
|
const listeners = [...this.exitListeners];
|
|
523
554
|
|
|
555
|
+
this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
|
|
556
|
+
|
|
524
557
|
this.stopFollowing();
|
|
525
558
|
|
|
526
559
|
for (const listener of listeners) {
|
|
@@ -537,7 +570,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
537
570
|
|
|
538
571
|
this.done = true;
|
|
539
572
|
this.connection = null;
|
|
573
|
+
this.exitConfirmed ??= false;
|
|
574
|
+
|
|
575
|
+
for (const waiter of this.exitWaiters) {
|
|
576
|
+
clearTimeout(waiter.timer);
|
|
577
|
+
|
|
578
|
+
waiter.waited.resolve(this.exitConfirmed);
|
|
579
|
+
}
|
|
540
580
|
|
|
581
|
+
this.exitWaiters.clear();
|
|
541
582
|
this.dataListeners.clear();
|
|
542
583
|
this.exitListeners.clear();
|
|
543
584
|
this.attachmentListeners.clear();
|
|
@@ -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
|
});
|
|
@@ -74,6 +74,35 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
74
74
|
kill: () => {
|
|
75
75
|
pty.kill();
|
|
76
76
|
},
|
|
77
|
+
|
|
78
|
+
// A process already gone has nothing left to end.
|
|
79
|
+
killForced: () => {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pty.pid, 'SIGKILL');
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!isMissingProcessError(error)) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// bun-pty's kill sends one SIGHUP and reports an exit at once, whether
|
|
90
|
+
// the process ended or not, so the exit is read from the process id
|
|
91
|
+
// instead: the library reaps its child, so the id stops answering a
|
|
92
|
+
// signal once the process is gone.
|
|
93
|
+
waitForExit: async (timeoutMs) => {
|
|
94
|
+
const deadline = Date.now() + timeoutMs;
|
|
95
|
+
|
|
96
|
+
while (isProcessRunning(pty.pid)) {
|
|
97
|
+
if (Date.now() >= deadline) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await Bun.sleep(20);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
77
106
|
detach: () => {
|
|
78
107
|
for (const subscription of subscriptions) {
|
|
79
108
|
subscription.dispose();
|
|
@@ -140,3 +169,19 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
140
169
|
return { exitCode, stdout, stderr };
|
|
141
170
|
}
|
|
142
171
|
}
|
|
172
|
+
|
|
173
|
+
// A process another user owns still runs, so only a missing process counts
|
|
174
|
+
// as gone.
|
|
175
|
+
function isProcessRunning(pid: number): boolean {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return !isMissingProcessError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isMissingProcessError(error: unknown): boolean {
|
|
186
|
+
return error instanceof Error && 'code' in error && error.code === 'ESRCH';
|
|
187
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
1
2
|
import { isRecord } from '../shared/report';
|
|
2
3
|
import { toSessionID } from '../shared/to-session-id';
|
|
3
|
-
import type { HookEvent } from './hooks';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reporter line as a hook event, or null for a line that is not one.
|
|
@@ -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
|
+
}
|