@hachej/boring-execution 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/aws-code-interpreter.d.ts +149 -0
- package/dist/aws-code-interpreter.js +453 -0
- package/dist/bounded-error.d.ts +8 -0
- package/dist/bounded-error.js +12 -0
- package/dist/contracts.d.ts +46 -0
- package/dist/contracts.js +1 -0
- package/dist/fs-readers.d.ts +7 -0
- package/dist/fs-readers.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/native.d.ts +8 -0
- package/dist/native.js +1 -0
- package/dist/remote-files-handler.d.ts +21 -0
- package/dist/remote-files-handler.js +215 -0
- package/dist/remote-files-io.d.ts +8 -0
- package/dist/remote-files-io.js +46 -0
- package/dist/remote-files-positional.d.ts +12 -0
- package/dist/remote-files-positional.js +208 -0
- package/dist/remote-files-protocol.d.ts +342 -0
- package/dist/remote-files-protocol.js +113 -0
- package/dist/remote-files-watch.d.ts +29 -0
- package/dist/remote-files-watch.js +154 -0
- package/dist/remote-files.d.ts +17 -0
- package/dist/remote-files.js +238 -0
- package/dist/remote-shell-handler.d.ts +25 -0
- package/dist/remote-shell-handler.js +131 -0
- package/dist/remote-shell-protocol.d.ts +50 -0
- package/dist/remote-shell-protocol.js +163 -0
- package/dist/remote-shell-window.d.ts +14 -0
- package/dist/remote-shell-window.js +82 -0
- package/dist/remote-shell.d.ts +15 -0
- package/dist/remote-shell.js +127 -0
- package/dist/virtual-filesystem.d.ts +67 -0
- package/dist/virtual-filesystem.js +218 -0
- package/dist/virtual-git.d.ts +60 -0
- package/dist/virtual-git.js +116 -0
- package/dist/virtual-sqlite.d.ts +42 -0
- package/dist/virtual-sqlite.js +50 -0
- package/dist/virtual.d.ts +34 -0
- package/dist/virtual.js +277 -0
- package/package.json +94 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { awaitWithContext, withAbortSignal } from '@earendil-works/chord/context';
|
|
3
|
+
import { FileError, ok, err } from '@earendil-works/pi-durable/env';
|
|
4
|
+
import { bytes, fileInfo, line, nothing, envelope, streamFrame, schema, version, nativeVersion, streamType, identity, sameIdentity, positiveLimit, result, wireCall, requestInput } from './remote-files-protocol.js';
|
|
5
|
+
import { frameReader, readJson } from './remote-files-io.js';
|
|
6
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
7
|
+
import { maxWatchExcludeNames, maxWatchTargets } from './remote-files-protocol.js';
|
|
8
|
+
import { remoteReaders } from './remote-files-positional.js';
|
|
9
|
+
import { watcherFromFrames } from './remote-files-watch.js';
|
|
10
|
+
export { createRemoteFileSystemHandler } from './remote-files-handler.js';
|
|
11
|
+
export function createRemoteFileSystemLease(options) {
|
|
12
|
+
const selected = identity(options.identity), filesystemId = z.string().min(1).parse(options.filesystemId);
|
|
13
|
+
const endpoint = new URL(options.endpoint), fetch = options.fetch;
|
|
14
|
+
if (!['https:', 'http:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
15
|
+
throw new TypeError('Expected an HTTP endpoint without credentials or fragment');
|
|
16
|
+
let cwd = z.string().parse(options.cwd), closed = false;
|
|
17
|
+
const maxRequestBytes = positiveLimit(options.maxRequestBytes ?? 1_048_576), maxResponseBytes = positiveLimit(options.maxResponseBytes ?? 4_194_304);
|
|
18
|
+
const maxFrameBytes = positiveLimit(options.maxFrameBytes ?? 1_048_576), active = new Set();
|
|
19
|
+
const unavailable = (message) => new FileError('unknown', message + '; prior remote effects may remain');
|
|
20
|
+
const connect = async (call, context) => {
|
|
21
|
+
if (closed)
|
|
22
|
+
return err(new FileError('invalid', 'Remote filesystem lease is closed'));
|
|
23
|
+
if (context.abortSignal?.aborted)
|
|
24
|
+
return err(new FileError('aborted', 'Filesystem operation aborted before dispatch'));
|
|
25
|
+
const abort = new AbortController(), requestId = randomUUID();
|
|
26
|
+
const onAbort = () => abort.abort(), detach = () => context.abortSignal?.removeEventListener('abort', onAbort);
|
|
27
|
+
const local = withAbortSignal(abort.signal, context);
|
|
28
|
+
let response, closeFrames, dispatched = false;
|
|
29
|
+
const dispose = async () => {
|
|
30
|
+
detach();
|
|
31
|
+
abort.abort();
|
|
32
|
+
active.delete(dispose);
|
|
33
|
+
if (closeFrames)
|
|
34
|
+
await closeFrames();
|
|
35
|
+
else if (response?.body)
|
|
36
|
+
void response.body.cancel().catch(() => { });
|
|
37
|
+
};
|
|
38
|
+
active.add(dispose);
|
|
39
|
+
context.abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
40
|
+
try {
|
|
41
|
+
const body = JSON.stringify({ schema, version, nativeVersion, requestId, identity: selected, filesystemId, cwd, call: wireCall(call) });
|
|
42
|
+
requestInput(JSON.parse(body));
|
|
43
|
+
if (new TextEncoder().encode(body).length > maxRequestBytes)
|
|
44
|
+
throw new TypeError('Filesystem request byte limit exceeded');
|
|
45
|
+
if (context.abortSignal?.aborted || closed) {
|
|
46
|
+
await dispose();
|
|
47
|
+
return err(new FileError('aborted', 'Filesystem operation aborted before dispatch'));
|
|
48
|
+
}
|
|
49
|
+
const request = new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: abort.signal, redirect: 'error' });
|
|
50
|
+
dispatched = true;
|
|
51
|
+
const pending = fetch(request);
|
|
52
|
+
void pending.then(value => { if (abort.signal.aborted)
|
|
53
|
+
void value.body?.cancel().catch(() => { }); }, () => { });
|
|
54
|
+
response = await awaitWithContext(pending, local);
|
|
55
|
+
if (response.redirected || response.status !== 200 || !response.body)
|
|
56
|
+
throw new TypeError('Filesystem response unavailable');
|
|
57
|
+
return ok({ response, requestId, local, abort, detach, dispose, setFrames: (close) => { closeFrames = close; } });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
const cancelled = context.abortSignal?.aborted === true;
|
|
61
|
+
await dispose();
|
|
62
|
+
if (cancelled)
|
|
63
|
+
return err(new FileError('aborted', dispatched ? 'Filesystem operation aborted; prior remote effects may remain' : 'Filesystem operation aborted before dispatch'));
|
|
64
|
+
return err(dispatched ? unavailable('Filesystem acknowledgement was not received') : new FileError('invalid', 'Invalid remote filesystem request'));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const matches = (value, requestId) => value.requestId === requestId && sameIdentity(value.identity, selected) && value.filesystemId === filesystemId;
|
|
68
|
+
const invoke = async (call, output, context) => {
|
|
69
|
+
const connected = await connect(call, context);
|
|
70
|
+
if (!connected.ok)
|
|
71
|
+
return connected;
|
|
72
|
+
const connection = connected.value;
|
|
73
|
+
try {
|
|
74
|
+
if (connection.response.headers.get('content-type')?.split(';')[0]?.trim() !== 'application/json')
|
|
75
|
+
throw new TypeError('Invalid filesystem response type');
|
|
76
|
+
const value = envelope.parse(await readJson(connection.response.body, connection.local, maxResponseBytes));
|
|
77
|
+
if (!matches(value, connection.requestId) || connection.abort.signal.aborted)
|
|
78
|
+
throw new TypeError('Filesystem response identity mismatch');
|
|
79
|
+
return result(value.result, output);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
if (context.abortSignal?.aborted)
|
|
83
|
+
return err(new FileError('aborted', 'Filesystem operation aborted; prior remote effects may remain'));
|
|
84
|
+
return err(unavailable('Filesystem acknowledgement was invalid or interrupted'));
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await connection.dispose();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const open = async (path, context) => {
|
|
91
|
+
const connected = await connect({ method: 'openTextLineReader', args: [path] }, context);
|
|
92
|
+
if (!connected.ok)
|
|
93
|
+
return connected;
|
|
94
|
+
const connection = connected.value;
|
|
95
|
+
let retained = false;
|
|
96
|
+
try {
|
|
97
|
+
if (connection.response.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json') {
|
|
98
|
+
const value = envelope.parse(await readJson(connection.response.body, connection.local, maxResponseBytes));
|
|
99
|
+
if (!matches(value, connection.requestId))
|
|
100
|
+
throw new TypeError('Filesystem response identity mismatch');
|
|
101
|
+
return result(value.result, z.never());
|
|
102
|
+
}
|
|
103
|
+
if (connection.response.headers.get('content-type')?.split(';')[0]?.trim() !== streamType || !connection.response.body)
|
|
104
|
+
throw new TypeError('Invalid filesystem stream type');
|
|
105
|
+
const frames = frameReader(connection.response.body, maxFrameBytes);
|
|
106
|
+
connection.setFrames(frames.close);
|
|
107
|
+
const first = streamFrame.parse(await awaitWithContext(frames.next(), connection.local));
|
|
108
|
+
if (first.type !== 'opened' || !matches(first, connection.requestId) || connection.abort.signal.aborted)
|
|
109
|
+
throw new TypeError('Filesystem stream identity mismatch');
|
|
110
|
+
connection.detach();
|
|
111
|
+
let sequence = 1, pending, busy = false, ended = false, readerClosed = false;
|
|
112
|
+
const close = async () => { if (readerClosed)
|
|
113
|
+
return; readerClosed = true; await connection.dispose(); };
|
|
114
|
+
const reader = {
|
|
115
|
+
close,
|
|
116
|
+
readLine: async (readContext) => {
|
|
117
|
+
if (readContext.abortSignal?.aborted)
|
|
118
|
+
return err(new FileError('aborted', 'Read wait was cancelled; the line has not been consumed'));
|
|
119
|
+
if (readerClosed || closed)
|
|
120
|
+
return err(new FileError('invalid', 'Remote line reader is closed'));
|
|
121
|
+
if (ended)
|
|
122
|
+
return ok(undefined);
|
|
123
|
+
if (connection.abort.signal.aborted)
|
|
124
|
+
return err(new FileError('invalid', 'Remote line reader is closed'));
|
|
125
|
+
if (busy)
|
|
126
|
+
return err(new FileError('invalid', 'A line read is already pending'));
|
|
127
|
+
busy = true;
|
|
128
|
+
try {
|
|
129
|
+
pending ??= frames.next();
|
|
130
|
+
const value = await awaitWithContext(pending, withAbortSignal(connection.abort.signal, readContext));
|
|
131
|
+
if (readContext.abortSignal?.aborted)
|
|
132
|
+
return err(new FileError('aborted', 'Read wait was cancelled; the line has not been consumed'));
|
|
133
|
+
if (readerClosed || connection.abort.signal.aborted)
|
|
134
|
+
throw new TypeError('Reader closed during read');
|
|
135
|
+
const frame = streamFrame.parse(value);
|
|
136
|
+
if (frame.requestId !== connection.requestId || frame.sequence !== sequence || frame.type === 'opened' || frame.type === 'change')
|
|
137
|
+
throw new TypeError('Filesystem line sequence mismatch');
|
|
138
|
+
if (frame.type === 'end') {
|
|
139
|
+
pending = frames.next().then(tail => { if (tail !== undefined)
|
|
140
|
+
throw new TypeError('Data after filesystem EOF'); return value; });
|
|
141
|
+
await awaitWithContext(pending, withAbortSignal(connection.abort.signal, readContext));
|
|
142
|
+
if (readContext.abortSignal?.aborted)
|
|
143
|
+
return err(new FileError('aborted', 'Read wait was cancelled; the line has not been consumed'));
|
|
144
|
+
pending = undefined;
|
|
145
|
+
ended = true;
|
|
146
|
+
await connection.dispose();
|
|
147
|
+
return ok(undefined);
|
|
148
|
+
}
|
|
149
|
+
const decoded = result(frame.result, line);
|
|
150
|
+
pending = undefined;
|
|
151
|
+
sequence++;
|
|
152
|
+
return decoded;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
if (readContext.abortSignal?.aborted && !connection.abort.signal.aborted)
|
|
156
|
+
return err(new FileError('aborted', 'Read wait was cancelled; the line has not been consumed'));
|
|
157
|
+
await close();
|
|
158
|
+
return err(unavailable('Remote line observation was interrupted'));
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
busy = false;
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
retained = true;
|
|
166
|
+
return ok(reader);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return err(unavailable('Remote line reader could not be opened'));
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (!retained)
|
|
173
|
+
await connection.dispose();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const release = async () => { closed = true; await Promise.all([...active].map(close => close())); };
|
|
177
|
+
// Pi's readers and watcher travel as request-bound positional calls and one streamed watch request, never as whole-file reads.
|
|
178
|
+
const readers = remoteReaders(invoke, maxResponseBytes);
|
|
179
|
+
const watch = async (targets, onChange, context) => {
|
|
180
|
+
if (targets.length < 1 || targets.length > maxWatchTargets)
|
|
181
|
+
return err(new FileError('invalid', `A watch needs between 1 and ${maxWatchTargets} targets`));
|
|
182
|
+
if (targets.some(target => (target.exclude?.names?.length ?? 0) > maxWatchExcludeNames))
|
|
183
|
+
return err(new FileError('invalid', `A watch target excludes at most ${maxWatchExcludeNames} names`));
|
|
184
|
+
const connected = await connect({ method: 'watch', args: [targets] }, context);
|
|
185
|
+
if (!connected.ok)
|
|
186
|
+
return connected;
|
|
187
|
+
const connection = connected.value;
|
|
188
|
+
let retained = false;
|
|
189
|
+
try {
|
|
190
|
+
if (connection.response.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json') {
|
|
191
|
+
const value = envelope.parse(await readJson(connection.response.body, connection.local, maxResponseBytes));
|
|
192
|
+
if (!matches(value, connection.requestId))
|
|
193
|
+
throw new TypeError('Filesystem response identity mismatch');
|
|
194
|
+
return result(value.result, z.never());
|
|
195
|
+
}
|
|
196
|
+
if (connection.response.headers.get('content-type')?.split(';')[0]?.trim() !== streamType || !connection.response.body)
|
|
197
|
+
throw new TypeError('Invalid filesystem stream type');
|
|
198
|
+
const frames = frameReader(connection.response.body, maxFrameBytes);
|
|
199
|
+
connection.setFrames(frames.close);
|
|
200
|
+
const first = streamFrame.parse(await awaitWithContext(frames.next(), connection.local));
|
|
201
|
+
if (first.type !== 'opened' || first.mode === undefined || !matches(first, connection.requestId) || connection.abort.signal.aborted)
|
|
202
|
+
throw new TypeError('Filesystem stream identity mismatch');
|
|
203
|
+
connection.detach();
|
|
204
|
+
retained = true;
|
|
205
|
+
return ok(watcherFromFrames({ mode: first.mode, next: frames.next, requestId: connection.requestId, onChange, dispose: connection.dispose }));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return err(unavailable('Remote file watcher could not be opened'));
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (!retained)
|
|
212
|
+
await connection.dispose();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const environment = {
|
|
216
|
+
...readers, watch, id: filesystemId, get cwd() { return cwd; }, set cwd(value) { cwd = value; }, cleanup: release,
|
|
217
|
+
absolutePath: (path, context) => invoke({ method: 'absolutePath', args: [path] }, z.string(), context),
|
|
218
|
+
joinPath: (parts, context) => invoke({ method: 'joinPath', args: [parts] }, z.string(), context),
|
|
219
|
+
readTextFile: (path, context) => invoke({ method: 'readTextFile', args: [path] }, z.string(), context),
|
|
220
|
+
openTextLineReader: open,
|
|
221
|
+
readTextLines: (path, options, context) => invoke({ method: 'readTextLines', args: [path, options] }, z.array(z.string()), context),
|
|
222
|
+
readBinaryFile: (path, context) => invoke({ method: 'readBinaryFile', args: [path] }, bytes, context),
|
|
223
|
+
writeFile: (path, content, context) => invoke({ method: 'writeFile', args: [path, content] }, nothing, context),
|
|
224
|
+
appendFile: (path, content, context) => invoke({ method: 'appendFile', args: [path, content] }, nothing, context),
|
|
225
|
+
truncateFile: (path, size, context) => invoke({ method: 'truncateFile', args: [path, size] }, nothing, context),
|
|
226
|
+
flushFile: (path, context) => invoke({ method: 'flushFile', args: [path] }, nothing, context),
|
|
227
|
+
renameFile: (from, to, context) => invoke({ method: 'renameFile', args: [from, to] }, nothing, context),
|
|
228
|
+
fileInfo: (path, context) => invoke({ method: 'fileInfo', args: [path] }, fileInfo, context),
|
|
229
|
+
listDir: (path, context) => invoke({ method: 'listDir', args: [path] }, z.array(fileInfo), context),
|
|
230
|
+
canonicalPath: (path, context) => invoke({ method: 'canonicalPath', args: [path] }, z.string(), context),
|
|
231
|
+
exists: (path, context) => invoke({ method: 'exists', args: [path] }, z.boolean(), context),
|
|
232
|
+
createDir: (path, options, context) => invoke({ method: 'createDir', args: [path, options] }, nothing, context),
|
|
233
|
+
remove: (path, options, context) => invoke({ method: 'remove', args: [path, options] }, nothing, context),
|
|
234
|
+
createTempDir: (prefix, context) => invoke({ method: 'createTempDir', args: [prefix] }, z.string(), context),
|
|
235
|
+
createTempFile: (options, context) => invoke({ method: 'createTempFile', args: [options] }, z.string(), context),
|
|
236
|
+
};
|
|
237
|
+
return { identity: selected, environment, ownership: 'borrowed', release };
|
|
238
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord';
|
|
2
|
+
import type { Shell, ShellExecOptions } from '@earendil-works/pi-durable/env';
|
|
3
|
+
import type { WorkspaceIdentity } from './contracts.js';
|
|
4
|
+
export interface RemoteShellAccess {
|
|
5
|
+
readonly identity: WorkspaceIdentity;
|
|
6
|
+
readonly shell: Shell;
|
|
7
|
+
readonly context: Context;
|
|
8
|
+
/** Abort on authorization/identity changes, including while the command is silent. */
|
|
9
|
+
readonly revoked: AbortSignal;
|
|
10
|
+
readonly authorize: (command: string, options: Readonly<Omit<ShellExecOptions, 'onOutput'>>) => boolean | Promise<boolean>;
|
|
11
|
+
/** Host qualification of the selected native Shell. Spill paths remain in that workspace. */
|
|
12
|
+
readonly supports: {
|
|
13
|
+
readonly timeout: boolean;
|
|
14
|
+
readonly spill: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface RemoteShellHandlerOptions {
|
|
18
|
+
/** Authenticate, enforce origin/CSRF policy, and select an existing borrowed workspace. */
|
|
19
|
+
readonly authenticate: (request: Request) => Promise<RemoteShellAccess | null>;
|
|
20
|
+
readonly maxRequestBytes?: number;
|
|
21
|
+
readonly maxFrameBytes?: number;
|
|
22
|
+
readonly maxQueuedBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function createRemoteShellHandler(options: RemoteShellHandlerOptions): (request: Request) => Promise<Response>;
|
|
25
|
+
//# sourceMappingURL=remote-shell-handler.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { awaitWithContext, withAbortSignal } from '@earendil-works/chord/context';
|
|
2
|
+
import { ExecutionError, err } from '@earendil-works/pi-durable/env';
|
|
3
|
+
import { guardStatus, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
|
|
4
|
+
import { windowedOutput } from './remote-shell-window.js';
|
|
5
|
+
import { contentType, identity, nativeVersion, positiveLimit, requestInput, sameIdentity, schema, version, wireResult } from './remote-shell-protocol.js';
|
|
6
|
+
function failure(status) {
|
|
7
|
+
return new Response(null, { status, headers: { 'cache-control': 'no-store' } });
|
|
8
|
+
}
|
|
9
|
+
export function createRemoteShellHandler(options) {
|
|
10
|
+
const maxRequestBytes = positiveLimit(options.maxRequestBytes ?? 65_536), maxFrameBytes = positiveLimit(options.maxFrameBytes ?? 1_048_576);
|
|
11
|
+
const maxQueuedBytes = positiveLimit(options.maxQueuedBytes ?? 4_194_304);
|
|
12
|
+
if (maxQueuedBytes < maxFrameBytes)
|
|
13
|
+
throw new TypeError('Remote shell queue must hold one maximum-sized frame');
|
|
14
|
+
return async (request) => {
|
|
15
|
+
if (request.method !== 'POST')
|
|
16
|
+
return failure(405);
|
|
17
|
+
if (!hasJsonContentType(request.headers))
|
|
18
|
+
return failure(415);
|
|
19
|
+
let access;
|
|
20
|
+
try {
|
|
21
|
+
access = await options.authenticate(request);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return failure(503);
|
|
25
|
+
}
|
|
26
|
+
if (!access)
|
|
27
|
+
return failure(401);
|
|
28
|
+
const { shell, authorize, revoked, supports } = access;
|
|
29
|
+
const selected = identity(access.identity), stop = new AbortController();
|
|
30
|
+
const context = withAbortSignal(AbortSignal.any([request.signal, revoked, stop.signal]), access.context);
|
|
31
|
+
const signal = context.abortSignal;
|
|
32
|
+
let input;
|
|
33
|
+
try {
|
|
34
|
+
input = requestInput(await readJsonBody(request, maxRequestBytes, signal));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return failure(guardStatus(error));
|
|
38
|
+
}
|
|
39
|
+
if (!sameIdentity(input.identity, selected))
|
|
40
|
+
return failure(409);
|
|
41
|
+
if (input.options.timeout !== undefined && supports.timeout !== true || input.options.spill !== undefined && supports.spill !== true)
|
|
42
|
+
return failure(422);
|
|
43
|
+
try {
|
|
44
|
+
if (signal?.aborted || await awaitWithContext(Promise.resolve(authorize(input.command, structuredClone(input.options))), context) !== true || signal?.aborted)
|
|
45
|
+
return failure(403);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return failure(503);
|
|
49
|
+
}
|
|
50
|
+
let ended = false, sequence = 0;
|
|
51
|
+
let controller;
|
|
52
|
+
function fail() {
|
|
53
|
+
if (ended)
|
|
54
|
+
return;
|
|
55
|
+
ended = true;
|
|
56
|
+
signal?.removeEventListener('abort', fail);
|
|
57
|
+
controller.error(new Error('Remote shell stream interrupted; command outcome is unconfirmed'));
|
|
58
|
+
stop.abort();
|
|
59
|
+
}
|
|
60
|
+
function send(value) {
|
|
61
|
+
if (ended || signal?.aborted)
|
|
62
|
+
throw new Error('Remote shell transport closed');
|
|
63
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ ...value, requestId: input.requestId, sequence: sequence++ }) + '\n');
|
|
64
|
+
if (bytes.byteLength > maxFrameBytes || (controller.desiredSize ?? 0) < bytes.byteLength) {
|
|
65
|
+
fail();
|
|
66
|
+
throw new Error('Remote shell output limit exceeded');
|
|
67
|
+
}
|
|
68
|
+
controller.enqueue(bytes);
|
|
69
|
+
}
|
|
70
|
+
async function execute() {
|
|
71
|
+
try {
|
|
72
|
+
if (signal?.aborted || ended)
|
|
73
|
+
return;
|
|
74
|
+
const emit = (chunk) => send({ type: 'output', text: chunk.text, stream: chunk.stream, ...(chunk.skipped === undefined ? {} : { skipped: chunk.skipped }) });
|
|
75
|
+
const windowed = input.output && input.options.window !== undefined ? windowedOutput(input.options.window, emit) : undefined;
|
|
76
|
+
const onOutput = !input.output ? undefined : windowed ? (text, _context, info) => windowed.push(text, info)
|
|
77
|
+
: (text, _context, info) => emit({ text, stream: info.stream, ...(info.skipped === undefined ? {} : { skipped: info.skipped }) });
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = await shell.exec(input.command, { ...input.options, ...(onOutput ? { onOutput } : {}) }, context);
|
|
81
|
+
windowed?.finish();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
windowed?.cancel();
|
|
85
|
+
}
|
|
86
|
+
if (await awaitWithContext(Promise.resolve(authorize(input.command, structuredClone(input.options))), context) !== true || signal?.aborted || ended) {
|
|
87
|
+
fail();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
send({ type: 'result', result: wireResult(result) });
|
|
91
|
+
ended = true;
|
|
92
|
+
signal?.removeEventListener('abort', fail);
|
|
93
|
+
controller.close();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
if (!ended && !signal?.aborted) {
|
|
97
|
+
try {
|
|
98
|
+
send({ type: 'result', result: wireResult(err(new ExecutionError('unknown', 'Remote provider failed; prior effects may remain'))) });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
fail();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ended = true;
|
|
105
|
+
signal?.removeEventListener('abort', fail);
|
|
106
|
+
controller.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const body = new ReadableStream({
|
|
111
|
+
start(value) {
|
|
112
|
+
controller = value;
|
|
113
|
+
signal?.addEventListener('abort', fail, { once: true });
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
fail();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
send({ type: 'header', schema, version, nativeVersion, identity: selected });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
fail();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
void execute();
|
|
126
|
+
},
|
|
127
|
+
cancel() { fail(); },
|
|
128
|
+
}, { highWaterMark: maxQueuedBytes, size: bytes => bytes.byteLength });
|
|
129
|
+
return new Response(body, { headers: { 'content-type': contentType, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } });
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ExecutionError } from '@earendil-works/pi-durable/env';
|
|
2
|
+
import type { Shell, ShellExecOptions, ShellOutputSkip, ExecutionErrorCode } from '@earendil-works/pi-durable/env';
|
|
3
|
+
import type { WorkspaceIdentity } from './contracts.js';
|
|
4
|
+
export declare const schema = "boring.remote-shell";
|
|
5
|
+
export declare const version = 2;
|
|
6
|
+
export declare const nativeVersion = "pi-durable@1.1.0";
|
|
7
|
+
export declare const contentType = "application/x-ndjson";
|
|
8
|
+
export type WireOptions = Omit<ShellExecOptions, 'onOutput'>;
|
|
9
|
+
export type ShellResult = Awaited<ReturnType<Shell['exec']>>;
|
|
10
|
+
export declare function identity(value: unknown): WorkspaceIdentity;
|
|
11
|
+
export declare function sameIdentity(left: WorkspaceIdentity, right: WorkspaceIdentity): boolean;
|
|
12
|
+
export declare function wireOptions(value: unknown): WireOptions;
|
|
13
|
+
/** Output the native environment omitted before a chunk (Pi `ShellOutputSkip`), validated off the wire. */
|
|
14
|
+
export declare function skipped(value: unknown): ShellOutputSkip;
|
|
15
|
+
export declare function requestInput(value: unknown): {
|
|
16
|
+
requestId: string;
|
|
17
|
+
identity: WorkspaceIdentity;
|
|
18
|
+
command: string;
|
|
19
|
+
options: WireOptions;
|
|
20
|
+
output: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare function wireResult(value: ShellResult): {
|
|
23
|
+
ok: true;
|
|
24
|
+
value: import("@earendil-works/pi-durable/env").ShellExecResult;
|
|
25
|
+
} | {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
error: {
|
|
28
|
+
spillPath?: string;
|
|
29
|
+
code: ExecutionErrorCode;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
export declare function frame(value: unknown, requestId: string, sequence: number): {
|
|
34
|
+
readonly type: "header";
|
|
35
|
+
readonly identity: WorkspaceIdentity;
|
|
36
|
+
readonly result?: never;
|
|
37
|
+
} | {
|
|
38
|
+
readonly skipped?: ShellOutputSkip;
|
|
39
|
+
readonly type: "output";
|
|
40
|
+
readonly text: string;
|
|
41
|
+
readonly stream: "stdout" | "stderr";
|
|
42
|
+
readonly identity?: never;
|
|
43
|
+
readonly result?: never;
|
|
44
|
+
} | {
|
|
45
|
+
readonly type: "result";
|
|
46
|
+
readonly result: import("@earendil-works/pi-durable/env").Result<import("@earendil-works/pi-durable/env").ShellExecResult, ExecutionError>;
|
|
47
|
+
readonly identity?: never;
|
|
48
|
+
};
|
|
49
|
+
export declare function positiveLimit(value: number): number;
|
|
50
|
+
//# sourceMappingURL=remote-shell-protocol.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ExecutionError, ok, err } from '@earendil-works/pi-durable/env';
|
|
2
|
+
export const schema = 'boring.remote-shell';
|
|
3
|
+
export const version = 2;
|
|
4
|
+
export const nativeVersion = 'pi-durable@1.1.0';
|
|
5
|
+
export const contentType = 'application/x-ndjson';
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function record(value) {
|
|
10
|
+
if (!isRecord(value))
|
|
11
|
+
throw new Error('Expected an object');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function keys(value, allowed) {
|
|
15
|
+
if (Object.keys(value).some(key => !allowed.includes(key)))
|
|
16
|
+
throw new Error('Unexpected protocol field');
|
|
17
|
+
}
|
|
18
|
+
function string(value) {
|
|
19
|
+
if (typeof value !== 'string')
|
|
20
|
+
throw new Error('Expected a string');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function identity(value) {
|
|
24
|
+
const data = record(value);
|
|
25
|
+
keys(data, ['providerId', 'instanceId', 'incarnation', 'viewId']);
|
|
26
|
+
const result = { providerId: string(data['providerId']), instanceId: string(data['instanceId']), incarnation: string(data['incarnation']), viewId: string(data['viewId']) };
|
|
27
|
+
if (Object.values(result).some(value => !value || /[\x00-\x1f]/.test(value)))
|
|
28
|
+
throw new Error('Invalid workspace identity');
|
|
29
|
+
return Object.freeze(result);
|
|
30
|
+
}
|
|
31
|
+
export function sameIdentity(left, right) {
|
|
32
|
+
return left.providerId === right.providerId && left.instanceId === right.instanceId && left.incarnation === right.incarnation && left.viewId === right.viewId;
|
|
33
|
+
}
|
|
34
|
+
export function wireOptions(value) {
|
|
35
|
+
const data = record(value);
|
|
36
|
+
keys(data, ['cwd', 'env', 'inheritEnv', 'timeout', 'spill', 'window']);
|
|
37
|
+
const result = {};
|
|
38
|
+
if (data['cwd'] !== undefined)
|
|
39
|
+
result.cwd = string(data['cwd']);
|
|
40
|
+
if (data['env'] !== undefined) {
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (const [key, value] of Object.entries(record(data['env'])))
|
|
43
|
+
entries.push([key, string(value)]);
|
|
44
|
+
result.env = Object.fromEntries(entries);
|
|
45
|
+
}
|
|
46
|
+
if (data['inheritEnv'] !== undefined) {
|
|
47
|
+
if (typeof data['inheritEnv'] !== 'boolean')
|
|
48
|
+
throw new Error('Invalid inheritEnv');
|
|
49
|
+
result.inheritEnv = data['inheritEnv'];
|
|
50
|
+
}
|
|
51
|
+
if (data['timeout'] !== undefined) {
|
|
52
|
+
if (typeof data['timeout'] !== 'number' || (!Number.isFinite(data['timeout']) || data['timeout'] <= 0))
|
|
53
|
+
throw new Error('Invalid timeout');
|
|
54
|
+
result.timeout = data['timeout'];
|
|
55
|
+
}
|
|
56
|
+
if (data['spill'] !== undefined) {
|
|
57
|
+
const spill = record(data['spill']);
|
|
58
|
+
keys(spill, ['afterBytes', 'afterLines']);
|
|
59
|
+
const afterBytes = spill['afterBytes'], afterLines = spill['afterLines'];
|
|
60
|
+
if (typeof afterBytes !== 'number' || !Number.isSafeInteger(afterBytes) || afterBytes < 0
|
|
61
|
+
|| typeof afterLines !== 'number' || !Number.isSafeInteger(afterLines) || afterLines < 0)
|
|
62
|
+
throw new Error('Invalid spill thresholds');
|
|
63
|
+
result.spill = { afterBytes, afterLines };
|
|
64
|
+
}
|
|
65
|
+
if (data['window'] !== undefined) {
|
|
66
|
+
const window = record(data['window']);
|
|
67
|
+
keys(window, ['maxBytes', 'maxLines', 'minIntervalMs', 'bytesPerSecond']);
|
|
68
|
+
const limits = {};
|
|
69
|
+
for (const key of ['maxBytes', 'maxLines', 'minIntervalMs', 'bytesPerSecond']) {
|
|
70
|
+
const limit = window[key];
|
|
71
|
+
if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 0)
|
|
72
|
+
throw new Error('Invalid output window');
|
|
73
|
+
limits[key] = limit;
|
|
74
|
+
}
|
|
75
|
+
result.window = { maxBytes: limits['maxBytes'], maxLines: limits['maxLines'], minIntervalMs: limits['minIntervalMs'], bytesPerSecond: limits['bytesPerSecond'] };
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/** Output the native environment omitted before a chunk (Pi `ShellOutputSkip`), validated off the wire. */
|
|
80
|
+
export function skipped(value) {
|
|
81
|
+
const data = record(value);
|
|
82
|
+
keys(data, ['bytes', 'newlines', 'endsWithNewline']);
|
|
83
|
+
const bytes = data['bytes'], newlines = data['newlines'], endsWithNewline = data['endsWithNewline'];
|
|
84
|
+
if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 0 || typeof newlines !== 'number' || !Number.isSafeInteger(newlines) || newlines < 0 || typeof endsWithNewline !== 'boolean')
|
|
85
|
+
throw new Error('Invalid skipped output');
|
|
86
|
+
return { bytes, newlines, endsWithNewline };
|
|
87
|
+
}
|
|
88
|
+
export function requestInput(value) {
|
|
89
|
+
const data = record(value);
|
|
90
|
+
keys(data, ['schema', 'version', 'nativeVersion', 'requestId', 'identity', 'command', 'options', 'output']);
|
|
91
|
+
if (data['schema'] !== schema || data['version'] !== version || data['nativeVersion'] !== nativeVersion)
|
|
92
|
+
throw new Error('Unsupported remote shell protocol');
|
|
93
|
+
const requestId = string(data['requestId']);
|
|
94
|
+
if (!requestId || requestId.length > 128)
|
|
95
|
+
throw new Error('Invalid request identity');
|
|
96
|
+
if (typeof data['output'] !== 'boolean')
|
|
97
|
+
throw new Error('Invalid output selection');
|
|
98
|
+
return { requestId, identity: identity(data['identity']), command: string(data['command']), options: wireOptions(data['options']), output: data['output'] };
|
|
99
|
+
}
|
|
100
|
+
function errorCode(value) {
|
|
101
|
+
switch (value) {
|
|
102
|
+
case 'aborted':
|
|
103
|
+
case 'timeout':
|
|
104
|
+
case 'shell_unavailable':
|
|
105
|
+
case 'spawn_error':
|
|
106
|
+
case 'callback_error':
|
|
107
|
+
case 'unknown': return value;
|
|
108
|
+
default: throw new Error('Invalid native execution error');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function result(value) {
|
|
112
|
+
const data = record(value);
|
|
113
|
+
if (data['ok'] === true) {
|
|
114
|
+
keys(data, ['ok', 'value']);
|
|
115
|
+
const value = record(data['value']);
|
|
116
|
+
keys(value, ['exitCode', 'spillPath']);
|
|
117
|
+
const exitCode = value['exitCode'];
|
|
118
|
+
if (typeof exitCode !== 'number' || !Number.isSafeInteger(exitCode))
|
|
119
|
+
throw new Error('Invalid exit code');
|
|
120
|
+
return ok({ exitCode, ...(value['spillPath'] === undefined ? {} : { spillPath: string(value['spillPath']) }) });
|
|
121
|
+
}
|
|
122
|
+
if (data['ok'] === false) {
|
|
123
|
+
keys(data, ['ok', 'error']);
|
|
124
|
+
const value = record(data['error']);
|
|
125
|
+
keys(value, ['code', 'message', 'spillPath']);
|
|
126
|
+
const error = new ExecutionError(errorCode(value['code']), string(value['message']));
|
|
127
|
+
if (value['spillPath'] !== undefined)
|
|
128
|
+
error.spillPath = string(value['spillPath']);
|
|
129
|
+
return err(error);
|
|
130
|
+
}
|
|
131
|
+
throw new Error('Invalid native execution result');
|
|
132
|
+
}
|
|
133
|
+
export function wireResult(value) {
|
|
134
|
+
if (value.ok)
|
|
135
|
+
return value;
|
|
136
|
+
return { ok: false, error: { code: value.error.code, message: value.error.message, ...(value.error.spillPath === undefined ? {} : { spillPath: value.error.spillPath }) } };
|
|
137
|
+
}
|
|
138
|
+
export function frame(value, requestId, sequence) {
|
|
139
|
+
const data = record(value);
|
|
140
|
+
if (data['requestId'] !== requestId || data['sequence'] !== sequence)
|
|
141
|
+
throw new Error('Remote shell frame binding mismatch');
|
|
142
|
+
switch (data['type']) {
|
|
143
|
+
case 'header':
|
|
144
|
+
keys(data, ['type', 'requestId', 'sequence', 'schema', 'version', 'nativeVersion', 'identity']);
|
|
145
|
+
if (data['schema'] !== schema || data['version'] !== version || data['nativeVersion'] !== nativeVersion)
|
|
146
|
+
throw new Error('Unsupported remote shell protocol');
|
|
147
|
+
return { type: 'header', identity: identity(data['identity']) };
|
|
148
|
+
case 'output':
|
|
149
|
+
keys(data, ['type', 'requestId', 'sequence', 'text', 'stream', 'skipped']);
|
|
150
|
+
if (data['stream'] !== 'stdout' && data['stream'] !== 'stderr')
|
|
151
|
+
throw new Error('Invalid remote shell output stream');
|
|
152
|
+
return { type: 'output', text: string(data['text']), stream: data['stream'], ...(data['skipped'] === undefined ? {} : { skipped: skipped(data['skipped']) }) };
|
|
153
|
+
case 'result':
|
|
154
|
+
keys(data, ['type', 'requestId', 'sequence', 'result']);
|
|
155
|
+
return { type: 'result', result: result(data['result']) };
|
|
156
|
+
default: throw new Error('Unknown remote shell frame');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function positiveLimit(value) {
|
|
160
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
161
|
+
throw new TypeError('Remote shell limits must be positive safe integers');
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ShellOutputInfo, ShellOutputSkip, ShellOutputWindow } from '@earendil-works/pi-durable/env';
|
|
2
|
+
export interface WindowedOutput {
|
|
3
|
+
readonly text: string;
|
|
4
|
+
readonly stream: 'stdout' | 'stderr';
|
|
5
|
+
readonly skipped?: ShellOutputSkip;
|
|
6
|
+
}
|
|
7
|
+
/** Collects output, drops what the window cannot keep, and flushes no faster than `minIntervalMs`. */
|
|
8
|
+
export declare function windowedOutput(window: ShellOutputWindow, emit: (chunk: WindowedOutput) => void): {
|
|
9
|
+
push(chunk: string, info: ShellOutputInfo): void;
|
|
10
|
+
/** Deliver what is buffered before the terminal result. */
|
|
11
|
+
finish: () => void;
|
|
12
|
+
cancel(): void;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=remote-shell-window.d.ts.map
|