@shardflux/sdk 0.14.0 → 0.16.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/CHANGELOG.md +85 -23
- package/README.md +226 -7
- package/dist/account.d.ts +2 -0
- package/dist/account.js +6 -0
- package/dist/cell.d.ts +27 -0
- package/dist/cell.js +71 -0
- package/dist/client.d.ts +54 -5
- package/dist/client.js +100 -7
- package/dist/computer.d.ts +153 -0
- package/dist/computer.js +229 -0
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +9 -0
- package/dist/executions.d.ts +2 -6
- package/dist/executions.js +9 -0
- package/dist/exit-code.d.ts +7 -0
- package/dist/exit-code.js +12 -0
- package/dist/generated/app-api.d.ts +1055 -119
- package/dist/generated/cell-api.d.ts +334 -0
- package/dist/http.d.ts +7 -1
- package/dist/http.js +34 -13
- package/dist/index.d.ts +13 -6
- package/dist/index.js +7 -2
- package/dist/ports.d.ts +7 -0
- package/dist/ports.js +1 -1
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +1 -1
- package/dist/templates.d.ts +12 -0
- package/dist/templates.js +9 -0
- package/dist/testing/index.d.ts +62 -0
- package/dist/testing/index.js +585 -0
- package/dist/testing/seed.d.ts +433 -0
- package/dist/testing/seed.js +449 -0
- package/dist/tools.d.ts +21 -3
- package/dist/tools.js +113 -22
- package/dist/tunnel-assets/linux-amd64.gz +0 -0
- package/dist/tunnel-assets/linux-arm64.gz +0 -0
- package/dist/tunnel-assets.d.ts +10 -0
- package/dist/tunnel-assets.js +11 -0
- package/dist/tunnel-packet.d.ts +3 -0
- package/dist/tunnel-packet.js +43 -0
- package/dist/tunnel-pty.d.ts +86 -0
- package/dist/tunnel-pty.js +243 -0
- package/dist/tunnels.d.ts +47 -0
- package/dist/tunnels.js +454 -0
- package/dist/workspace-ref.d.ts +87 -0
- package/dist/workspace-ref.js +173 -0
- package/dist/workspace.d.ts +40 -1
- package/dist/workspace.js +111 -2
- package/package.json +7 -2
package/dist/tunnels.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createConnection } from 'node:net';
|
|
4
|
+
import { gunzipSync } from 'node:zlib';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
|
|
7
|
+
import { TUNNEL_ASSETS } from "./tunnel-assets.js";
|
|
8
|
+
import { PtyTunnelTransport } from "./tunnel-pty.js";
|
|
9
|
+
const READY = 1, OPEN = 2, DATA = 3, FIN = 4, RESET = 5, CREDIT = 6;
|
|
10
|
+
const CHUNK = 65536, WINDOW = 4194304;
|
|
11
|
+
export function tunnelTarget(target) {
|
|
12
|
+
const m = /^(?:\[([^\]]+)\]|([^\s:[\]]+)):([0-9]+)$/.exec(target);
|
|
13
|
+
const port = Number(m?.[3]);
|
|
14
|
+
if (!m || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
15
|
+
throw new TypeError('target must be host:port or [IPv6]:port (1..65535)');
|
|
16
|
+
return { host: m[1] ?? m[2], port };
|
|
17
|
+
}
|
|
18
|
+
function frame(kind, id, body = Buffer.alloc(0)) {
|
|
19
|
+
const h = Buffer.alloc(9);
|
|
20
|
+
h[0] = kind;
|
|
21
|
+
h.writeUInt32BE(id, 1);
|
|
22
|
+
h.writeUInt32BE(body.length, 5);
|
|
23
|
+
return Buffer.concat([h, body]);
|
|
24
|
+
}
|
|
25
|
+
function retryable(e) {
|
|
26
|
+
if (e instanceof ShardfluxApiError)
|
|
27
|
+
return e.retryable || e.code === 'workspace_not_running' || e.reason === 'workspace_not_running';
|
|
28
|
+
return e instanceof TypeError || (e instanceof ShardfluxProtocolError && e.status === 0);
|
|
29
|
+
}
|
|
30
|
+
/** An owned foreground connection to the developer machine. Close in finally. */
|
|
31
|
+
export class ReverseTunnel {
|
|
32
|
+
sessionId;
|
|
33
|
+
remotePort;
|
|
34
|
+
target;
|
|
35
|
+
transport;
|
|
36
|
+
closed;
|
|
37
|
+
#resolve;
|
|
38
|
+
#reject;
|
|
39
|
+
#ready;
|
|
40
|
+
#readyError;
|
|
41
|
+
#transport;
|
|
42
|
+
#dir;
|
|
43
|
+
#target;
|
|
44
|
+
#controller = new AbortController();
|
|
45
|
+
#peers = new Map();
|
|
46
|
+
#writes = [];
|
|
47
|
+
#sending = false;
|
|
48
|
+
#inputOffset = 0;
|
|
49
|
+
#nextInputOffset = 0;
|
|
50
|
+
#newWrites;
|
|
51
|
+
#outputOffset = 0;
|
|
52
|
+
#stderrOffset = 0;
|
|
53
|
+
#buffer = Buffer.alloc(0);
|
|
54
|
+
#stderr = '';
|
|
55
|
+
#closing;
|
|
56
|
+
#stats = { connections: 0, activeConnections: 0, bytesToTarget: 0, bytesToWorkspace: 0, reconnects: 0 };
|
|
57
|
+
#abort;
|
|
58
|
+
#signal;
|
|
59
|
+
constructor(transport, sessionId, dir, opts) {
|
|
60
|
+
this.#transport = transport;
|
|
61
|
+
this.sessionId = sessionId;
|
|
62
|
+
this.#dir = dir;
|
|
63
|
+
this.remotePort = opts.remotePort;
|
|
64
|
+
this.target = opts.target;
|
|
65
|
+
this.#target = tunnelTarget(opts.target);
|
|
66
|
+
this.transport = transport.kind ?? 'exec';
|
|
67
|
+
this.closed = new Promise((resolve, reject) => { this.#resolve = resolve; this.#reject = reject; });
|
|
68
|
+
// Ownership remains with the handle even when the caller observes failure later through closed.
|
|
69
|
+
void this.closed.catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
get stats() { return { ...this.#stats, activeConnections: this.#peers.size }; }
|
|
72
|
+
/** Internal transport entry point, also exercised against the real helper in tests. */
|
|
73
|
+
static async attach(transport, sessionId, dir, opts) {
|
|
74
|
+
const t = new ReverseTunnel(transport, sessionId, dir, opts);
|
|
75
|
+
const ready = new Promise((resolve, reject) => { t.#ready = resolve; t.#readyError = reject; });
|
|
76
|
+
const timer = setTimeout(() => t.#fail(new Error('reverse tunnel listener did not become ready within 30 seconds')), 30_000);
|
|
77
|
+
t.#signal = opts.signal;
|
|
78
|
+
t.#abort = () => { void t.close().catch(() => { }); };
|
|
79
|
+
opts.signal?.addEventListener('abort', t.#abort, { once: true });
|
|
80
|
+
void t.#read().catch(e => t.#fail(e));
|
|
81
|
+
if (opts.signal?.aborted)
|
|
82
|
+
t.#fail(opts.signal.reason);
|
|
83
|
+
try {
|
|
84
|
+
await ready;
|
|
85
|
+
return t;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
await t.close();
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
#fail(e) {
|
|
96
|
+
if (this.#closing)
|
|
97
|
+
return;
|
|
98
|
+
this.#readyError(e);
|
|
99
|
+
void this.#teardown(e).catch(() => { });
|
|
100
|
+
}
|
|
101
|
+
#send(kind, id, body) {
|
|
102
|
+
if (this.#closing)
|
|
103
|
+
return Promise.reject(new Error('reverse tunnel is closed'));
|
|
104
|
+
const result = new Promise((resolve, reject) => { this.#writes.push({ bytes: frame(kind, id, body), resolve, reject }); });
|
|
105
|
+
this.#newWrites?.();
|
|
106
|
+
if (!this.#sending) {
|
|
107
|
+
this.#sending = true;
|
|
108
|
+
queueMicrotask(() => { void this.#flush().catch(e => this.#fail(e)); });
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
async #flush() {
|
|
113
|
+
const active = new Set();
|
|
114
|
+
while ((this.#writes.length || active.size) && !this.#closing) {
|
|
115
|
+
while (this.#writes.length && active.size < (this.#transport.maxInputConcurrency ?? 1)) {
|
|
116
|
+
const batch = [], complete = [], parts = [];
|
|
117
|
+
let size = 0;
|
|
118
|
+
while (this.#writes.length && size < 65536) {
|
|
119
|
+
const w = this.#writes[0];
|
|
120
|
+
const n = Math.min(w.bytes.length, 65536 - size);
|
|
121
|
+
batch.push(w);
|
|
122
|
+
parts.push(w.bytes.subarray(0, n));
|
|
123
|
+
size += n;
|
|
124
|
+
if (n === w.bytes.length) {
|
|
125
|
+
this.#writes.shift();
|
|
126
|
+
complete.push(w);
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
w.bytes = w.bytes.subarray(n);
|
|
130
|
+
}
|
|
131
|
+
const bytes = Buffer.concat(parts);
|
|
132
|
+
const offset = this.#nextInputOffset;
|
|
133
|
+
this.#nextInputOffset += bytes.length;
|
|
134
|
+
const work = this.#writeBatch(bytes, offset).then(() => { for (const w of complete)
|
|
135
|
+
w.resolve(); }, (e) => {
|
|
136
|
+
for (const w of batch)
|
|
137
|
+
w.reject(e);
|
|
138
|
+
this.#fail(e);
|
|
139
|
+
});
|
|
140
|
+
active.add(work);
|
|
141
|
+
void work.then(() => active.delete(work));
|
|
142
|
+
}
|
|
143
|
+
if (active.size)
|
|
144
|
+
await Promise.race([...active, new Promise(resolve => { this.#newWrites = resolve; })]);
|
|
145
|
+
}
|
|
146
|
+
this.#newWrites = undefined;
|
|
147
|
+
this.#sending = false;
|
|
148
|
+
}
|
|
149
|
+
async #writeBatch(bytes, start) {
|
|
150
|
+
let sent = 0;
|
|
151
|
+
while (sent < bytes.length && !this.#closing) {
|
|
152
|
+
try {
|
|
153
|
+
const ack = await this.#transport.exec.input(this.sessionId, bytes.subarray(sent), { offset: start + sent, signal: this.#controller.signal });
|
|
154
|
+
const n = ack.offset - (start + sent);
|
|
155
|
+
if (!Number.isInteger(n) || n < 0 || n > bytes.length - sent || ack.closed)
|
|
156
|
+
throw new Error('invalid reverse tunnel stdin acknowledgement');
|
|
157
|
+
sent += n;
|
|
158
|
+
this.#inputOffset = Math.max(this.#inputOffset, ack.offset);
|
|
159
|
+
if (n === 0)
|
|
160
|
+
await delay(25, undefined, { signal: this.#controller.signal });
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
if (!retryable(e) || this.#closing)
|
|
164
|
+
throw e;
|
|
165
|
+
this.#stats.reconnects++;
|
|
166
|
+
await delay(250, undefined, { signal: this.#controller.signal });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async #read() {
|
|
171
|
+
while (!this.#closing) {
|
|
172
|
+
try {
|
|
173
|
+
const events = await this.#transport.exec.output(this.sessionId, { stdoutOffset: this.#outputOffset, stderrOffset: this.#stderrOffset, signal: this.#controller.signal });
|
|
174
|
+
for await (const ev of events) {
|
|
175
|
+
if (this.#closing)
|
|
176
|
+
return;
|
|
177
|
+
if (ev.type === 'exit')
|
|
178
|
+
throw new Error(`reverse tunnel forwarder exited${this.#stderr ? ': ' + this.#stderr : ''}`);
|
|
179
|
+
if (ev.type === 'error') {
|
|
180
|
+
if (ev.error)
|
|
181
|
+
throw new ShardfluxApiError(502, ev.error, 'cell');
|
|
182
|
+
throw new Error('reverse tunnel output failed without an error envelope');
|
|
183
|
+
}
|
|
184
|
+
if (ev.type !== 'output')
|
|
185
|
+
continue;
|
|
186
|
+
const bytes = Buffer.from(ev.data ?? '', 'base64');
|
|
187
|
+
const stderr = ev.stream === 'stderr';
|
|
188
|
+
const offset = stderr ? this.#stderrOffset : this.#outputOffset;
|
|
189
|
+
const start = ev.offset ?? offset;
|
|
190
|
+
if (start > offset)
|
|
191
|
+
throw new Error('reverse tunnel output has a byte gap');
|
|
192
|
+
const fresh = bytes.subarray(Math.min(bytes.length, Math.max(0, offset - start)));
|
|
193
|
+
if (stderr) {
|
|
194
|
+
this.#stderrOffset += fresh.length;
|
|
195
|
+
this.#stderr = (this.#stderr + fresh.toString()).slice(-4096);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
this.#outputOffset += fresh.length;
|
|
199
|
+
this.#decode(fresh);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
if (this.#closing)
|
|
205
|
+
return;
|
|
206
|
+
if (!retryable(e))
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
this.#stats.reconnects++;
|
|
210
|
+
await delay(250, undefined, { signal: this.#controller.signal });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
#decode(bytes) {
|
|
214
|
+
this.#buffer = Buffer.concat([this.#buffer, bytes]);
|
|
215
|
+
while (this.#buffer.length >= 9) {
|
|
216
|
+
const n = this.#buffer.readUInt32BE(5);
|
|
217
|
+
if (n > 65536)
|
|
218
|
+
throw new Error('oversized reverse tunnel frame');
|
|
219
|
+
if (this.#buffer.length < n + 9)
|
|
220
|
+
return;
|
|
221
|
+
const kind = this.#buffer[0];
|
|
222
|
+
const id = this.#buffer.readUInt32BE(1);
|
|
223
|
+
const body = this.#buffer.subarray(9, n + 9);
|
|
224
|
+
this.#buffer = this.#buffer.subarray(n + 9);
|
|
225
|
+
this.#dispatch(kind, id, body);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
#dispatch(kind, id, body) {
|
|
229
|
+
if (kind === READY && id === 0 && body.length === 0) {
|
|
230
|
+
this.#ready();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (kind === OPEN && id !== 0 && body.length === 0) {
|
|
234
|
+
if (this.#peers.has(id) || this.#peers.size >= 128)
|
|
235
|
+
throw new Error('invalid reverse tunnel connection');
|
|
236
|
+
const socket = createConnection({ ...this.#target, allowHalfOpen: true });
|
|
237
|
+
const p = { socket, credit: WINDOW, receiveCredit: WINDOW, pendingCredit: 0, remoteEnded: false, localEnded: false };
|
|
238
|
+
this.#peers.set(id, p);
|
|
239
|
+
this.#stats.connections++;
|
|
240
|
+
socket.on('error', () => this.#reset(id));
|
|
241
|
+
// Normal TCP EOF can close the socket while #pump still awaits acknowledgement of its final chunk.
|
|
242
|
+
socket.on('close', () => { if (!socket.readableEnded && this.#peers.get(id) === p)
|
|
243
|
+
this.#reset(id); });
|
|
244
|
+
void this.#pump(id, p).catch(() => this.#reset(id));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const p = this.#peers.get(id);
|
|
248
|
+
if (!p) {
|
|
249
|
+
if (![DATA, FIN, RESET, CREDIT].includes(kind))
|
|
250
|
+
throw new Error('unknown reverse tunnel frame');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (kind === DATA) {
|
|
254
|
+
if (!body.length || p.remoteEnded || body.length > p.receiveCredit)
|
|
255
|
+
throw new Error('reverse tunnel receive window overflow');
|
|
256
|
+
p.receiveCredit -= body.length;
|
|
257
|
+
this.#stats.bytesToTarget += body.length;
|
|
258
|
+
p.socket.write(body, (e) => {
|
|
259
|
+
if (e || this.#peers.get(id) !== p)
|
|
260
|
+
return;
|
|
261
|
+
p.pendingCredit += body.length;
|
|
262
|
+
if (p.pendingCredit >= 262144) {
|
|
263
|
+
const credit = Buffer.alloc(4);
|
|
264
|
+
credit.writeUInt32BE(p.pendingCredit);
|
|
265
|
+
p.receiveCredit += p.pendingCredit;
|
|
266
|
+
p.pendingCredit = 0;
|
|
267
|
+
void this.#send(CREDIT, id, credit).catch(e => this.#fail(e));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
else if (kind === CREDIT) {
|
|
272
|
+
if (body.length !== 4 || !body.readUInt32BE(0) || p.credit + body.readUInt32BE(0) > WINDOW)
|
|
273
|
+
throw new Error('invalid reverse tunnel credit');
|
|
274
|
+
p.credit += body.readUInt32BE(0);
|
|
275
|
+
p.wake?.();
|
|
276
|
+
}
|
|
277
|
+
else if (kind === FIN && body.length === 0) {
|
|
278
|
+
if (p.remoteEnded)
|
|
279
|
+
throw new Error('duplicate reverse tunnel fin');
|
|
280
|
+
p.remoteEnded = true;
|
|
281
|
+
p.socket.end();
|
|
282
|
+
this.#ended(id, p);
|
|
283
|
+
}
|
|
284
|
+
else if (kind === RESET && body.length === 0)
|
|
285
|
+
this.#reset(id, false);
|
|
286
|
+
else
|
|
287
|
+
throw new Error('invalid reverse tunnel frame');
|
|
288
|
+
}
|
|
289
|
+
async #pump(id, p) {
|
|
290
|
+
const pending = [];
|
|
291
|
+
for await (const value of p.socket) {
|
|
292
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
293
|
+
let pos = 0;
|
|
294
|
+
while (pos < bytes.length && this.#peers.get(id) === p && !this.#closing) {
|
|
295
|
+
if (!p.credit)
|
|
296
|
+
await new Promise(resolve => { p.wake = resolve; });
|
|
297
|
+
if (this.#peers.get(id) !== p || this.#closing)
|
|
298
|
+
return;
|
|
299
|
+
const n = Math.min(CHUNK, p.credit, bytes.length - pos);
|
|
300
|
+
p.credit -= n;
|
|
301
|
+
if (this.#transport.kind === 'pty') {
|
|
302
|
+
const write = this.#send(DATA, id, bytes.subarray(pos, pos + n));
|
|
303
|
+
void write.catch(() => { });
|
|
304
|
+
pending.push(write);
|
|
305
|
+
if (pending.length >= 128)
|
|
306
|
+
await Promise.all(pending.splice(0));
|
|
307
|
+
}
|
|
308
|
+
else
|
|
309
|
+
await this.#send(DATA, id, bytes.subarray(pos, pos + n));
|
|
310
|
+
pos += n;
|
|
311
|
+
this.#stats.bytesToWorkspace += n;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (this.#peers.get(id) !== p || this.#closing)
|
|
315
|
+
return;
|
|
316
|
+
await Promise.all(pending);
|
|
317
|
+
// A guest can release this connection and accept the next before the HTTP stdin acknowledgement arrives.
|
|
318
|
+
p.localEnded = true;
|
|
319
|
+
this.#ended(id, p);
|
|
320
|
+
await this.#send(FIN, id);
|
|
321
|
+
}
|
|
322
|
+
#ended(id, p) { if (p.localEnded && p.remoteEnded) {
|
|
323
|
+
this.#peers.delete(id);
|
|
324
|
+
p.wake?.();
|
|
325
|
+
} }
|
|
326
|
+
#reset(id, notify = true) {
|
|
327
|
+
const p = this.#peers.get(id);
|
|
328
|
+
if (!p)
|
|
329
|
+
return;
|
|
330
|
+
this.#peers.delete(id);
|
|
331
|
+
p.wake?.();
|
|
332
|
+
p.socket.destroy();
|
|
333
|
+
if (notify && !this.#closing)
|
|
334
|
+
void this.#send(RESET, id).catch(e => this.#fail(e));
|
|
335
|
+
}
|
|
336
|
+
close() { return this.#teardown(); }
|
|
337
|
+
#teardown(failure) {
|
|
338
|
+
if (this.#closing)
|
|
339
|
+
return this.#closing;
|
|
340
|
+
this.#closing = (async () => {
|
|
341
|
+
// Yield so #closing is set before output abort and socket callbacks run.
|
|
342
|
+
await Promise.resolve();
|
|
343
|
+
this.#readyError(failure ?? new Error('reverse tunnel closed before ready'));
|
|
344
|
+
this.#controller.abort();
|
|
345
|
+
this.#newWrites?.();
|
|
346
|
+
if (this.#abort)
|
|
347
|
+
this.#signal?.removeEventListener('abort', this.#abort);
|
|
348
|
+
for (const id of this.#peers.keys())
|
|
349
|
+
this.#reset(id, false);
|
|
350
|
+
for (const w of this.#writes.splice(0))
|
|
351
|
+
w.reject(new Error('reverse tunnel closed'));
|
|
352
|
+
const errors = failure === undefined ? [] : [failure];
|
|
353
|
+
try {
|
|
354
|
+
await this.#transport.exec.cancel(this.sessionId, 1000);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
errors.push(e);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await this.#transport.files.remove(this.#dir, { recursive: true });
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
errors.push(e);
|
|
364
|
+
}
|
|
365
|
+
if (errors.length) {
|
|
366
|
+
const e = new AggregateError(errors, 'reverse tunnel ended with errors');
|
|
367
|
+
this.#reject(e);
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
this.#resolve();
|
|
371
|
+
})();
|
|
372
|
+
return this.#closing;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Workspace → this client's machine; unused workspaces pay no tunnel setup or lifecycle cost. */
|
|
376
|
+
export class WorkspaceTunnels {
|
|
377
|
+
#cell;
|
|
378
|
+
#grants;
|
|
379
|
+
#owned = new Set();
|
|
380
|
+
constructor(cell, grants = () => null) { this.#cell = cell; this.#grants = grants; }
|
|
381
|
+
async reverse(opts) {
|
|
382
|
+
tunnelTarget(opts.target);
|
|
383
|
+
if (!Number.isInteger(opts.remotePort) || opts.remotePort < 1 || opts.remotePort > 65535)
|
|
384
|
+
throw new TypeError('remotePort must be 1..65535');
|
|
385
|
+
const bind = opts.bindAddress ?? '127.0.0.1';
|
|
386
|
+
if (bind !== '127.0.0.1' && bind !== '0.0.0.0')
|
|
387
|
+
throw new TypeError('bindAddress must be 127.0.0.1 or 0.0.0.0');
|
|
388
|
+
const choice = opts.transport ?? 'auto';
|
|
389
|
+
if (!['auto', 'pty', 'exec'].includes(choice))
|
|
390
|
+
throw new TypeError('transport must be auto, pty or exec');
|
|
391
|
+
opts.signal?.throwIfAborted();
|
|
392
|
+
const cell = this.#cell();
|
|
393
|
+
const archResult = await cell.exec.run(['uname', '-m'], { signal: opts.signal, timeoutMs: 5000 });
|
|
394
|
+
const arch = archResult.stdout.trim();
|
|
395
|
+
if (arch !== 'x86_64' && arch !== 'aarch64')
|
|
396
|
+
throw new Error(`reverse tunnel: unsupported guest architecture ${arch}`);
|
|
397
|
+
const asset = TUNNEL_ASSETS[arch];
|
|
398
|
+
const binary = gunzipSync(readFileSync(new URL(`./tunnel-assets/${asset.file}`, import.meta.url)));
|
|
399
|
+
if (createHash('sha256').update(binary).digest('hex') !== asset.sha256)
|
|
400
|
+
throw new Error('reverse tunnel helper checksum mismatch');
|
|
401
|
+
const dir = `/tmp/shardflux-tunnel-${randomUUID()}`;
|
|
402
|
+
const sessionId = randomUUID().replaceAll('-', '');
|
|
403
|
+
const fast = choice === 'pty' || (choice === 'auto' && this.#grants()?.includes('pty'));
|
|
404
|
+
const transport = fast ? new PtyTunnelTransport(cell) : cell;
|
|
405
|
+
let startAttempted = false, attached = false;
|
|
406
|
+
try {
|
|
407
|
+
await cell.files.mkdir(dir, { mode: '0700' });
|
|
408
|
+
await cell.files.write(`${dir}/forwarder`, binary, { mode: '0700' });
|
|
409
|
+
startAttempted = true;
|
|
410
|
+
const argv = [`${dir}/forwarder`, '--port', String(opts.remotePort), '--bind', bind];
|
|
411
|
+
const session = fast
|
|
412
|
+
? await cell.pty.open({ session_id: sessionId, argv: ['bash', '-c', 'stty raw -echo; exec "$@"', 'sh', ...argv, '--packets'] })
|
|
413
|
+
: await cell.exec.start({ session_id: sessionId, argv, stdin_open: true }, opts.signal);
|
|
414
|
+
if (session.state === 'failed_to_start')
|
|
415
|
+
throw new Error(`reverse tunnel could not start: ${session.error ?? ''}`);
|
|
416
|
+
attached = true;
|
|
417
|
+
const tunnel = await ReverseTunnel.attach(transport, sessionId, dir, opts);
|
|
418
|
+
this.#owned.add(tunnel);
|
|
419
|
+
void tunnel.closed.finally(() => this.#owned.delete(tunnel)).catch(() => { });
|
|
420
|
+
return tunnel;
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
if (attached)
|
|
424
|
+
throw e; // attach() already owns teardown and reports its errors.
|
|
425
|
+
// A start may have reached the guest even if its answer was lost.
|
|
426
|
+
const errors = [e];
|
|
427
|
+
if (startAttempted) {
|
|
428
|
+
try {
|
|
429
|
+
await transport.exec.cancel(sessionId, 1000);
|
|
430
|
+
}
|
|
431
|
+
catch (cleanup) {
|
|
432
|
+
if (!(cleanup instanceof ShardfluxApiError && cleanup.status === 404))
|
|
433
|
+
errors.push(cleanup);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await cell.files.remove(dir, { recursive: true });
|
|
438
|
+
}
|
|
439
|
+
catch (cleanup) {
|
|
440
|
+
if (!(cleanup instanceof ShardfluxApiError && cleanup.status === 404))
|
|
441
|
+
errors.push(cleanup);
|
|
442
|
+
}
|
|
443
|
+
if (errors.length > 1)
|
|
444
|
+
throw new AggregateError(errors, 'reverse tunnel startup cleanup failed', { cause: e });
|
|
445
|
+
throw e;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async close() {
|
|
449
|
+
const results = await Promise.allSettled([...this.#owned].map(t => t.close()));
|
|
450
|
+
const errors = results.filter((r) => r.status === 'rejected').map(r => r.reason);
|
|
451
|
+
if (errors.length)
|
|
452
|
+
throw new AggregateError(errors, 'reverse tunnel cleanup failed');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces by key (0.15.0+, contracts §46). `cloud.workspace(key, { template })` names a workspace without a request.
|
|
3
|
+
* Its first call opens the key (the workspace is created on first use and resumed afterwards, one held request that
|
|
4
|
+
* returns it with a tool token), and later calls go straight to the workspace, which wakes on use.
|
|
5
|
+
*/
|
|
6
|
+
import type { CellClient, CellClientOptions, RunOptions, RunResult } from './cell.js';
|
|
7
|
+
import { CAPTURE_BARRIER } from './cell.js';
|
|
8
|
+
import type { OpenParams, WorkspacesApi } from './client.js';
|
|
9
|
+
import type { WorkspaceMode } from './errors.js';
|
|
10
|
+
import type { ExecutionGetOptions, ExecutionResult, ExecutionRunOptions } from './executions.js';
|
|
11
|
+
import type { ToolName } from './tokens.js';
|
|
12
|
+
import type { ToolTarget, WorkspaceTool, WorkspaceToolsOptions } from './tools.js';
|
|
13
|
+
import type { HintOptions, HintResult, Workspace } from './workspace.js';
|
|
14
|
+
/**
|
|
15
|
+
* `cloud.workspace(key, params)`: the open's parameters without the key. `template` is required, unless `create: false`:
|
|
16
|
+
* then the first call finds the key's live workspace (a lookup, no VM start) and fails with ShardfluxApiError 404
|
|
17
|
+
* `not_found` when there is none. Default: the first call opens the key, creating the workspace.
|
|
18
|
+
*/
|
|
19
|
+
export type WorkspaceRefParams = (Omit<OpenParams, 'key'> & {
|
|
20
|
+
create?: true;
|
|
21
|
+
}) | (Omit<OpenParams, 'key' | 'template'> & {
|
|
22
|
+
template?: string;
|
|
23
|
+
create: false;
|
|
24
|
+
});
|
|
25
|
+
type Files = CellClient['files'];
|
|
26
|
+
type FileMethod = 'read' | 'readText' | 'readWithInfo' | 'write' | 'list' | 'stat' | 'remove' | 'mkdir' | 'move' | 'search' | 'patch';
|
|
27
|
+
/** The workspace's files, as on `cell().files`; each call opens the key first if needed. */
|
|
28
|
+
export type WorkspaceRefFiles = Pick<Files, FileMethod>;
|
|
29
|
+
/**
|
|
30
|
+
* A workspace named by its key. Nothing is requested until the first call: `exec()`, `files`, a tool from `tools()`,
|
|
31
|
+
* `hint()` or `open()`. Concurrent first calls share one open; a failed open is not kept, so the next call opens again.
|
|
32
|
+
* A workspace deleted under the ref (409 `workspace_deleted`, `isWorkspaceGone`) fails the call that meets it and is
|
|
33
|
+
* forgotten: the next call opens the key again (a new workspace, as `open()` would). `open()` returns the full
|
|
34
|
+
* `Workspace` (suspend, fork, ports, computer, tool-call capture).
|
|
35
|
+
*/
|
|
36
|
+
export declare class WorkspaceRef implements ToolTarget {
|
|
37
|
+
#private;
|
|
38
|
+
readonly key: string;
|
|
39
|
+
/** Internal: use `cloud.workspace(key, params)` or the module-level `workspace(key, params)`. */
|
|
40
|
+
constructor(api: WorkspacesApi, key: string, params: WorkspaceRefParams, grants: () => Promise<ToolName[] | null>);
|
|
41
|
+
/**
|
|
42
|
+
* Whether the open that produced the current workspace created it (contracts §46.2): undefined until the ref has
|
|
43
|
+
* opened (or when the API does not report it), false for `create: false`.
|
|
44
|
+
*/
|
|
45
|
+
get created(): boolean | undefined;
|
|
46
|
+
/** The opened workspace's mode, else the params' (`processful` when neither says). */
|
|
47
|
+
get mode(): WorkspaceMode;
|
|
48
|
+
/** Tools granted by the opened workspace's last token; null before the open. */
|
|
49
|
+
get grantedTools(): ToolName[] | null;
|
|
50
|
+
/** The workspace: opened (or, with `create: false`, found) on the first call and kept. */
|
|
51
|
+
open(): Promise<Workspace>;
|
|
52
|
+
/**
|
|
53
|
+
* Runs a command and collects its output (`cell().exec.run()`). A string runs through `bash -lc`, so shell syntax
|
|
54
|
+
* works; an argv array runs without a shell. A file-first workspace runs commands with `executions.run()`.
|
|
55
|
+
*/
|
|
56
|
+
exec(command: string | readonly string[], opts?: RunOptions): Promise<RunResult>;
|
|
57
|
+
/** The workspace's files, as on `cell().files`. */
|
|
58
|
+
readonly files: WorkspaceRefFiles;
|
|
59
|
+
/** A file-first workspace's executions (`run`, `get`), as on `Workspace.executions`. */
|
|
60
|
+
readonly executions: {
|
|
61
|
+
run: (argv: string[], opts?: ExecutionRunOptions) => Promise<ExecutionResult>;
|
|
62
|
+
get: (executionId: string, opts?: ExecutionGetOptions) => Promise<ExecutionResult>;
|
|
63
|
+
};
|
|
64
|
+
/** The workspace's cell client (`Workspace.cell()`), opening the key first if needed. */
|
|
65
|
+
cell(opts?: {
|
|
66
|
+
agentLabel?: string;
|
|
67
|
+
tools?: ToolName[];
|
|
68
|
+
} & CellClientOptions): Promise<CellClient>;
|
|
69
|
+
/** Hints without waiting, runs the turn, and requests idle suspension even if the body throws. */
|
|
70
|
+
turn<T>(fn: (workspace: this) => T | Promise<T>, { afterSeconds }?: {
|
|
71
|
+
afterSeconds?: number;
|
|
72
|
+
}): Promise<T>;
|
|
73
|
+
/**
|
|
74
|
+
* Says a tool call is coming. Before the first open it starts the open in the background (`wake` resolves when the
|
|
75
|
+
* workspace runs; nothing has to await it), unless `wake: null`; afterwards it is `Workspace.hint()`.
|
|
76
|
+
*/
|
|
77
|
+
hint(opts?: HintOptions): Promise<HintResult>;
|
|
78
|
+
/**
|
|
79
|
+
* Workspace tools for this key (`workspaceTools`), built before any VM exists: the definitions come from the API
|
|
80
|
+
* key's tool permissions (`GET /v1/me`, read once per client), and `computer` only with `computerUse: true` in the
|
|
81
|
+
* params or on an opened workspace whose computer use is on. The first tool call opens the key.
|
|
82
|
+
*/
|
|
83
|
+
tools(opts?: WorkspaceToolsOptions): Promise<WorkspaceTool[]>;
|
|
84
|
+
/** Internal: tool-call capture's read-your-writes barrier, once the workspace is open. */
|
|
85
|
+
[CAPTURE_BARRIER](): Promise<void> | undefined;
|
|
86
|
+
}
|
|
87
|
+
export {};
|