@zgeoff/atc 2.12.0 → 2.14.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 +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -12,15 +12,64 @@ export interface ExecutionProvider {
|
|
|
12
12
|
readonly kind: string;
|
|
13
13
|
readonly capabilities: ExecutionCapabilities;
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// Whether the host is a machine other than the daemon's own. A remote
|
|
16
|
+
// harness's files, transcripts included, live on that machine, and its
|
|
17
|
+
// environment holds only what atc sets for it.
|
|
18
|
+
readonly remote: boolean;
|
|
19
|
+
|
|
20
|
+
// Where a remote host keeps atc's files: the folder each session's own
|
|
21
|
+
// files unpack under, and the atc binary inside the host, null when the
|
|
22
|
+
// host has none. Absent on the daemon's own machine.
|
|
23
|
+
readonly guest?: GuestLayout;
|
|
24
|
+
|
|
25
|
+
// Readies the host a harness is about to start on: a remote host is
|
|
26
|
+
// created when missing, woken when asleep, and held awake while its
|
|
27
|
+
// harnesses run. Rejects with the refusal before any harness starts.
|
|
28
|
+
readonly prepareHost: (request: HostRequest) => Promise<void>;
|
|
29
|
+
|
|
30
|
+
// Starts a process in a pseudo-terminal of the given size, on the host the
|
|
31
|
+
// spec holds, which a prepare readied first.
|
|
16
32
|
readonly spawnHarness: (spec: HarnessSpec) => HarnessHandle;
|
|
17
33
|
|
|
18
|
-
// Unpacks a tar archive into a directory, creating the directory
|
|
34
|
+
// Unpacks a tar archive into a directory on a host, creating the directory
|
|
35
|
+
// first. The daemon's own machine takes no host.
|
|
19
36
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
|
|
20
|
-
readonly transferArchive: (archive: Uint8Array, dir: string) => Promise<void>;
|
|
37
|
+
readonly transferArchive: (archive: Uint8Array, dir: string, host?: string) => Promise<void>;
|
|
21
38
|
|
|
22
39
|
// Runs a command to completion and returns what it printed.
|
|
23
40
|
readonly runCommand: (spec: CommandSpec) => Promise<CommandResult>;
|
|
41
|
+
|
|
42
|
+
// Puts a host to sleep with every harness on it kept inside, so a revive
|
|
43
|
+
// finds each one as it was. Rejects with `host_leased` when another owner
|
|
44
|
+
// keeps the host awake, and leaves the host as it was then.
|
|
45
|
+
readonly suspendHost: (host: string) => Promise<void>;
|
|
46
|
+
|
|
47
|
+
// Deletes a host and everything on it, harnesses included. Nothing brings
|
|
48
|
+
// a destroyed host back.
|
|
49
|
+
readonly destroyHost: (host: string) => Promise<void>;
|
|
50
|
+
|
|
51
|
+
// Stops the provider's own background work when the daemon stops, and
|
|
52
|
+
// leaves every remote harness running for the next daemon to find.
|
|
53
|
+
readonly dispose: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The host a harness is about to start on, and the daemon that holds it:
|
|
58
|
+
* a remote host is held per daemon, so two daemons never release each
|
|
59
|
+
* other's hold.
|
|
60
|
+
*/
|
|
61
|
+
export interface HostRequest {
|
|
62
|
+
readonly host: string;
|
|
63
|
+
readonly daemonID: string;
|
|
64
|
+
|
|
65
|
+
// Whether the harness about to start needs atc inside the host, which a
|
|
66
|
+
// provider that ships its own binary installs when missing.
|
|
67
|
+
readonly installATC?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GuestLayout {
|
|
71
|
+
readonly dir: string;
|
|
72
|
+
readonly atc: string | null;
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
/**
|
|
@@ -63,12 +112,44 @@ export interface ExecutionCapabilities {
|
|
|
63
112
|
export type ExecutionCapability = keyof ExecutionCapabilities;
|
|
64
113
|
|
|
65
114
|
export interface HarnessSpec {
|
|
115
|
+
// The atc session the harness belongs to, and the host it runs on: the
|
|
116
|
+
// session's own id, or its parent's when the two share a host.
|
|
117
|
+
readonly session: string;
|
|
118
|
+
readonly host: string;
|
|
66
119
|
readonly bin: string;
|
|
67
120
|
readonly args: readonly string[];
|
|
68
121
|
readonly cwd: string;
|
|
122
|
+
|
|
123
|
+
// The variables atc sets for the harness. A provider for the daemon's
|
|
124
|
+
// own machine adds the daemon's environment around them; a remote one
|
|
125
|
+
// passes only these and its own.
|
|
69
126
|
readonly env: Readonly<Record<string, string>>;
|
|
127
|
+
|
|
128
|
+
// The variable names the harness goes without even when the daemon's
|
|
129
|
+
// environment holds them, such as a workspace's credential; none of the
|
|
130
|
+
// variables atc sets is withheld.
|
|
131
|
+
readonly withheldEnv?: readonly string[];
|
|
70
132
|
readonly cols: number;
|
|
71
133
|
readonly rows: number;
|
|
134
|
+
|
|
135
|
+
// Takes each connection a process of the harness opens to the daemon. A
|
|
136
|
+
// remote provider relays them from a socket inside the host that serves
|
|
137
|
+
// this harness alone, and points the harness's ATC_SOCKET at it.
|
|
138
|
+
readonly onRelay?: (relay: HarnessRelay) => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One connection from a process inside a harness's host to the daemon, in
|
|
143
|
+
* lines: each line the process writes arrives whole, and each line the
|
|
144
|
+
* daemon writes reaches the process in order.
|
|
145
|
+
*/
|
|
146
|
+
export interface HarnessRelay {
|
|
147
|
+
readonly onLine: (listener: (line: string) => void) => void;
|
|
148
|
+
readonly onClose: (listener: () => void) => void;
|
|
149
|
+
|
|
150
|
+
// Settles once the relay has room for more.
|
|
151
|
+
readonly writeLine: (line: string) => Promise<void>;
|
|
152
|
+
readonly close: () => void;
|
|
72
153
|
}
|
|
73
154
|
|
|
74
155
|
/**
|
|
@@ -78,22 +159,46 @@ export interface HarnessSpec {
|
|
|
78
159
|
export interface HarnessHandle {
|
|
79
160
|
readonly onData: (listener: (data: string) => void) => HarnessSubscription;
|
|
80
161
|
readonly onExit: (listener: (exit: HarnessExit) => void) => HarnessSubscription;
|
|
162
|
+
|
|
163
|
+
// Follows a remote harness's connection: lost and being restored, or
|
|
164
|
+
// restored. A harness on the daemon's own machine has no connection.
|
|
165
|
+
readonly onAttachment?: (
|
|
166
|
+
listener: (attachment: HarnessAttachment) => void,
|
|
167
|
+
) => HarnessSubscription;
|
|
81
168
|
readonly write: (data: string) => void;
|
|
82
169
|
readonly resize: (cols: number, rows: number) => void;
|
|
83
170
|
readonly kill: () => void;
|
|
171
|
+
|
|
172
|
+
// Stops following the harness and leaves its process running, for a host
|
|
173
|
+
// that keeps it after the daemon lets go; on a host that cannot keep it,
|
|
174
|
+
// the process ends as a kill ends it. No listener fires after a detach.
|
|
175
|
+
readonly detach: () => void;
|
|
84
176
|
}
|
|
85
177
|
|
|
178
|
+
export type HarnessAttachment = 'attached' | 'reattaching';
|
|
179
|
+
|
|
86
180
|
interface HarnessSubscription {
|
|
87
181
|
readonly dispose: () => void;
|
|
88
182
|
}
|
|
89
183
|
|
|
90
|
-
interface HarnessExit {
|
|
184
|
+
export interface HarnessExit {
|
|
91
185
|
readonly exitCode: number;
|
|
186
|
+
|
|
187
|
+
// Why the harness stopped: its process exited, its host went to sleep
|
|
188
|
+
// with the process inside, or the host lost it without an exit, such as
|
|
189
|
+
// a cold boot. Absent is an exit.
|
|
190
|
+
readonly reason?: 'exited' | 'suspended' | 'ended';
|
|
191
|
+
|
|
192
|
+
// What ended it, for a reason other than an exit.
|
|
193
|
+
readonly detail?: string;
|
|
92
194
|
}
|
|
93
195
|
|
|
94
196
|
export interface CommandSpec {
|
|
95
197
|
readonly argv: readonly string[];
|
|
96
198
|
readonly cwd: string;
|
|
199
|
+
|
|
200
|
+
// The host the command runs on; the daemon's own machine takes none.
|
|
201
|
+
readonly host?: string;
|
|
97
202
|
}
|
|
98
203
|
|
|
99
204
|
export interface CommandResult {
|
package/src/daemon/hooks.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { unlinkSync } from 'node:fs';
|
|
2
2
|
import { socketPath } from '../shared/config';
|
|
3
|
-
import { isRecord } from '../shared/report';
|
|
4
3
|
import type { SessionID } from '../shared/session-id';
|
|
5
|
-
import {
|
|
4
|
+
import { parseHookLine } from './parse-hook-line';
|
|
6
5
|
|
|
7
6
|
export interface HookEvent {
|
|
8
7
|
atcId: SessionID;
|
|
@@ -38,22 +37,11 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
38
37
|
continue;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
const parsed: unknown = JSON.parse(line);
|
|
40
|
+
const event = parseHookLine(line);
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
typeof parsed['event'] === 'string' &&
|
|
48
|
-
isRecord(parsed['payload'])
|
|
49
|
-
) {
|
|
50
|
-
onEvent({
|
|
51
|
-
atcId: toSessionID(parsed['atcId']),
|
|
52
|
-
event: parsed['event'],
|
|
53
|
-
payload: parsed['payload'],
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
} catch {}
|
|
42
|
+
if (event !== null) {
|
|
43
|
+
onEvent(event);
|
|
44
|
+
}
|
|
57
45
|
}
|
|
58
46
|
},
|
|
59
47
|
open(socket) {
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
|
|
2
|
+
import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
|
|
3
|
+
import type {
|
|
4
|
+
ImpCommand,
|
|
5
|
+
ImpCommandResult,
|
|
6
|
+
ImpCreateSpec,
|
|
7
|
+
ImpFeatures,
|
|
8
|
+
ImpLease,
|
|
9
|
+
ImpPort,
|
|
10
|
+
ImpRelayConnection,
|
|
11
|
+
ImpReverseForward,
|
|
12
|
+
ImpSessionConnection,
|
|
13
|
+
ImpSessionHandlers,
|
|
14
|
+
ImpSessionOutcome,
|
|
15
|
+
ImpSessionRequest,
|
|
16
|
+
ImpView,
|
|
17
|
+
} from './imp-port';
|
|
18
|
+
import { ImpPortError } from './imp-port-error';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where impd listens and the token the daemon calls it with. The token
|
|
22
|
+
* comes from the daemon's environment, never from a config file.
|
|
23
|
+
*/
|
|
24
|
+
export interface ImpClientPortOptions {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly token: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The imp port over `@zgeoff/imp-client`: RPC calls for imps and leases,
|
|
31
|
+
* one exec WebSocket per session connection, and one reverse forward per
|
|
32
|
+
* guest socket. Every refusal impd sends becomes an `ImpPortError` with its
|
|
33
|
+
* code and data, and a call that never reaches impd carries `UNREACHABLE`.
|
|
34
|
+
*/
|
|
35
|
+
export class ImpClientPort implements ImpPort {
|
|
36
|
+
private readonly url: string;
|
|
37
|
+
|
|
38
|
+
private readonly token: string | null;
|
|
39
|
+
|
|
40
|
+
private readonly client: ImpClient;
|
|
41
|
+
|
|
42
|
+
constructor(options: ImpClientPortOptions) {
|
|
43
|
+
this.url = options.url;
|
|
44
|
+
this.token = options.token;
|
|
45
|
+
|
|
46
|
+
this.client = createImpClient({
|
|
47
|
+
url: options.url,
|
|
48
|
+
...(options.token === null ? {} : { token: options.token }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// An impd from before the flags has neither.
|
|
53
|
+
readonly readFeatures = async (): Promise<ImpFeatures> => {
|
|
54
|
+
const info = await this.tryCall(() => this.client.system.info());
|
|
55
|
+
|
|
56
|
+
return info.features ?? { sessionOffsets: false, leases: false };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
readonly readImp = async (name: string): Promise<ImpView | null> => {
|
|
60
|
+
try {
|
|
61
|
+
const imp = await this.tryCall(() => this.client.imps.get({ name }));
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: imp.name,
|
|
65
|
+
state: imp.state,
|
|
66
|
+
leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
|
|
67
|
+
otherLeaseCount: imp.leases?.otherCount ?? 0,
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof ImpPortError && error.code === 'NOT_FOUND') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
readonly createImp = async (spec: ImpCreateSpec): Promise<ImpView> => {
|
|
79
|
+
const imp = await this.tryCall(() =>
|
|
80
|
+
this.client.imps.create({
|
|
81
|
+
name: spec.name,
|
|
82
|
+
...(spec.image === undefined ? {} : { image: spec.image }),
|
|
83
|
+
...(spec.memoryMib === undefined ? {} : { memoryMib: spec.memoryMib }),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
readonly acquireLease = async (
|
|
91
|
+
name: string,
|
|
92
|
+
label: string,
|
|
93
|
+
ttlSeconds: number,
|
|
94
|
+
): Promise<ImpLease> => {
|
|
95
|
+
const lease = await this.tryCall(() => this.client.leases.acquire({ name, label, ttlSeconds }));
|
|
96
|
+
|
|
97
|
+
return toLease(lease);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
readonly renewLease = async (
|
|
101
|
+
name: string,
|
|
102
|
+
label: string,
|
|
103
|
+
ttlSeconds: number,
|
|
104
|
+
): Promise<ImpLease> => {
|
|
105
|
+
const lease = await this.tryCall(() => this.client.leases.renew({ name, label, ttlSeconds }));
|
|
106
|
+
|
|
107
|
+
return toLease(lease);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
readonly releaseLease = async (name: string, label: string): Promise<boolean> => {
|
|
111
|
+
const result = await this.tryCall(() => this.client.leases.release({ name, label }));
|
|
112
|
+
|
|
113
|
+
return result.released;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
readonly suspendImp = async (name: string): Promise<void> => {
|
|
117
|
+
await this.tryCall(() => this.client.imps.sleep({ name }));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
readonly destroyImp = async (name: string): Promise<void> => {
|
|
121
|
+
await this.tryCall(() => this.client.imps.destroy({ name }));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// The exec WebSocket answers only after it opens, so nothing reaches the
|
|
125
|
+
// handlers before this returns.
|
|
126
|
+
readonly openSession = (
|
|
127
|
+
request: ImpSessionRequest,
|
|
128
|
+
handlers: ImpSessionHandlers,
|
|
129
|
+
): ImpSessionConnection => {
|
|
130
|
+
const session = openExecSession({
|
|
131
|
+
baseUrl: this.url,
|
|
132
|
+
token: this.token,
|
|
133
|
+
start:
|
|
134
|
+
request.kind === 'start'
|
|
135
|
+
? {
|
|
136
|
+
name: request.name,
|
|
137
|
+
session: request.session,
|
|
138
|
+
argv: request.argv,
|
|
139
|
+
tty: true,
|
|
140
|
+
env: request.env,
|
|
141
|
+
cwd: request.cwd,
|
|
142
|
+
cols: request.cols,
|
|
143
|
+
rows: request.rows,
|
|
144
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
name: request.name,
|
|
148
|
+
session: request.session,
|
|
149
|
+
cols: request.cols,
|
|
150
|
+
rows: request.rows,
|
|
151
|
+
wake: request.wake,
|
|
152
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
153
|
+
},
|
|
154
|
+
onStarted: (started) => {
|
|
155
|
+
handlers.onStarted({ created: started.created, output: started.output });
|
|
156
|
+
},
|
|
157
|
+
onOutput: (_channel, data) => {
|
|
158
|
+
handlers.onOutput(data);
|
|
159
|
+
},
|
|
160
|
+
connect: (url, headers) => new WebSocket(url, { headers }),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
outcome: waitForSessionOutcome(session.outcome),
|
|
165
|
+
write: (data) => {
|
|
166
|
+
session.sendStdin(data);
|
|
167
|
+
},
|
|
168
|
+
resize: (cols, rows) => {
|
|
169
|
+
session.resize(cols, rows);
|
|
170
|
+
},
|
|
171
|
+
sendSignal: (signal) => {
|
|
172
|
+
session.sendSignal(signal);
|
|
173
|
+
},
|
|
174
|
+
close: () => {
|
|
175
|
+
session.stop();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
|
|
181
|
+
readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
|
|
182
|
+
const result = await this.tryCall(() =>
|
|
183
|
+
this.client.run(name, command.argv, {
|
|
184
|
+
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
185
|
+
...(command.stdin === undefined ? {} : { stdin: command.stdin }),
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
readonly openReverseForward = (
|
|
193
|
+
name: string,
|
|
194
|
+
guestPath: string,
|
|
195
|
+
onConnection: (connection: ImpRelayConnection) => void,
|
|
196
|
+
): ImpReverseForward => {
|
|
197
|
+
const forward = openReverseForward({
|
|
198
|
+
baseUrl: this.url,
|
|
199
|
+
token: this.token,
|
|
200
|
+
name,
|
|
201
|
+
guest: { network: 'unix', path: guestPath },
|
|
202
|
+
connect: (url, headers) => new WebSocket(url, { headers }),
|
|
203
|
+
onConnection: (accept) => {
|
|
204
|
+
onConnection(openRelay(accept));
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
listening: waitForListening(forward.listening),
|
|
210
|
+
stop: () => {
|
|
211
|
+
forward.stop();
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Runs one client call, turning impd's refusals and transport failures
|
|
217
|
+
// into port errors.
|
|
218
|
+
private async tryCall<T>(call: () => Promise<T>): Promise<T> {
|
|
219
|
+
try {
|
|
220
|
+
return await call();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw toPortError(error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A lease as the client returns it.
|
|
228
|
+
interface ClientLease {
|
|
229
|
+
readonly name: string;
|
|
230
|
+
readonly owner: Readonly<{ principal: string; display: string; label: string }>;
|
|
231
|
+
readonly until: Date | null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a lease's end is a Date
|
|
235
|
+
function toLease(lease: ClientLease): ImpLease {
|
|
236
|
+
return {
|
|
237
|
+
name: lease.name,
|
|
238
|
+
owner: {
|
|
239
|
+
principal: lease.owner.principal,
|
|
240
|
+
display: lease.owner.display,
|
|
241
|
+
label: lease.owner.label,
|
|
242
|
+
},
|
|
243
|
+
until: lease.until?.getTime() ?? null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toPortError(error: unknown): ImpPortError {
|
|
248
|
+
if (error instanceof ImpPortError) {
|
|
249
|
+
return error;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// impd's refusals arrive as errors carrying impd's code and data.
|
|
253
|
+
if (error instanceof Error && 'code' in error && typeof error.code === 'string') {
|
|
254
|
+
const data: unknown = 'data' in error ? error.data : undefined;
|
|
255
|
+
|
|
256
|
+
return new ImpPortError(error.code, error.message, data);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
+
|
|
261
|
+
return new ImpPortError('UNREACHABLE', message);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
265
|
+
async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
|
|
266
|
+
const ended = await outcome;
|
|
267
|
+
|
|
268
|
+
if (ended.kind === 'local_error') {
|
|
269
|
+
return {
|
|
270
|
+
kind: 'local_error',
|
|
271
|
+
detail: ended.error instanceof Error ? ended.error.message : String(ended.error),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (ended.kind === 'unauthorized') {
|
|
276
|
+
return { kind: 'unauthorized' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return ended;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
283
|
+
async function waitForListening(listening: Promise<unknown>): Promise<void> {
|
|
284
|
+
await listening;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// How the client hands over a guest connection: the daemon accepts it with
|
|
288
|
+
// its handlers and gets the relay back.
|
|
289
|
+
type RelayAccept = (
|
|
290
|
+
handlers: Readonly<{
|
|
291
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
|
|
292
|
+
onData: (data: Uint8Array) => void;
|
|
293
|
+
onEof: () => void;
|
|
294
|
+
onClose: (lost: boolean) => void;
|
|
295
|
+
}>,
|
|
296
|
+
) => Readonly<{
|
|
297
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
|
|
298
|
+
send: (data: Uint8Array) => boolean;
|
|
299
|
+
waitForRoom: () => Promise<void>;
|
|
300
|
+
close: () => void;
|
|
301
|
+
}>;
|
|
302
|
+
|
|
303
|
+
// Takes a guest connection at once, handing its bytes and its close to the
|
|
304
|
+
// listeners the relay's owner adds.
|
|
305
|
+
function openRelay(accept: RelayAccept): ImpRelayConnection {
|
|
306
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
|
|
307
|
+
const dataListeners: ((data: Uint8Array) => void)[] = [];
|
|
308
|
+
const closeListeners: (() => void)[] = [];
|
|
309
|
+
|
|
310
|
+
const relay = accept({
|
|
311
|
+
onData: (data) => {
|
|
312
|
+
for (const listener of dataListeners) {
|
|
313
|
+
listener(data);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
onEof: () => {},
|
|
317
|
+
onClose: () => {
|
|
318
|
+
for (const listener of closeListeners) {
|
|
319
|
+
listener();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
onData: (listener) => {
|
|
326
|
+
dataListeners.push(listener);
|
|
327
|
+
},
|
|
328
|
+
onClose: (listener) => {
|
|
329
|
+
closeListeners.push(listener);
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// A send past the relay's window still queues the bytes, so only the
|
|
333
|
+
// next write waits; a closed relay takes bytes and drops them.
|
|
334
|
+
write: async (data) => {
|
|
335
|
+
if (!relay.send(data)) {
|
|
336
|
+
await relay.waitForRoom();
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
close: () => {
|
|
340
|
+
relay.close();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|