@platless/axom-cli 0.1.0 → 0.3.13
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 +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +37 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/cli.js +9 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, rename, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { ComputerFileError, computerFileError, resolveComputerDirCreatePath, resolveComputerDirPath, resolveComputerFilePath } from './filePaths.js';
|
|
6
|
+
export const MAX_READ_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
export const MAX_PAGE_BYTES = 256 * 1024;
|
|
8
|
+
export const MAX_WRITE_BYTES = 1024 * 1024;
|
|
9
|
+
// Transport-free primitives. Untrusted channel requests must use fileWorkerClient, which pins the
|
|
10
|
+
// parent directory before invoking these with resolvePinnedLeafPath; pathname-only resolution here
|
|
11
|
+
// is retained for unit tests and trusted callers, not as a security boundary against rename races.
|
|
12
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
function throwIfAborted(signal) {
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
throw new ComputerFileError('workstation_offline', 'File operation was cancelled');
|
|
16
|
+
}
|
|
17
|
+
async function snapshotFile(file, signal) {
|
|
18
|
+
let handle;
|
|
19
|
+
try {
|
|
20
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
21
|
+
const before = await handle.stat({ bigint: true });
|
|
22
|
+
if (!before.isFile())
|
|
23
|
+
throw new ComputerFileError('not_regular_file', 'Target is not a regular file');
|
|
24
|
+
if (before.size > BigInt(MAX_READ_BYTES))
|
|
25
|
+
throw new ComputerFileError('too_large', 'File exceeds 8 MiB');
|
|
26
|
+
const parts = [];
|
|
27
|
+
let total = 0;
|
|
28
|
+
while (true) {
|
|
29
|
+
throwIfAborted(signal);
|
|
30
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_READ_BYTES + 1 - total));
|
|
31
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
32
|
+
if (!bytesRead)
|
|
33
|
+
break;
|
|
34
|
+
total += bytesRead;
|
|
35
|
+
if (total > MAX_READ_BYTES)
|
|
36
|
+
throw new ComputerFileError('too_large', 'File exceeds 8 MiB');
|
|
37
|
+
parts.push(chunk.subarray(0, bytesRead));
|
|
38
|
+
}
|
|
39
|
+
const after = await handle.stat({ bigint: true });
|
|
40
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || BigInt(total) !== after.size) {
|
|
41
|
+
throw new ComputerFileError('conflict', 'File changed during read');
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(parts, total);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw computerFileError(error);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await handle?.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function readComputerFile(request, signal, resolvePath = resolveComputerFilePath) {
|
|
53
|
+
if (!Number.isSafeInteger(request.startLine) || request.startLine < 1 || !Number.isSafeInteger(request.maxLines) || request.maxLines < 1 || request.maxLines > 2000) {
|
|
54
|
+
throw new ComputerFileError('invalid_argument', 'Invalid line range');
|
|
55
|
+
}
|
|
56
|
+
const file = await resolvePath(request.root, request.path, false);
|
|
57
|
+
let bytes;
|
|
58
|
+
try {
|
|
59
|
+
bytes = await snapshotFile(file, signal);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!(error instanceof ComputerFileError) || error.code !== 'conflict')
|
|
63
|
+
throw error;
|
|
64
|
+
bytes = await snapshotFile(file, signal);
|
|
65
|
+
}
|
|
66
|
+
let source;
|
|
67
|
+
try {
|
|
68
|
+
source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new ComputerFileError('invalid_encoding', 'File is not valid UTF-8');
|
|
72
|
+
}
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (let begin = 0; begin < source.length;) {
|
|
75
|
+
const newline = source.indexOf('\n', begin);
|
|
76
|
+
const end = newline < 0 ? source.length : newline + 1;
|
|
77
|
+
lines.push(source.slice(begin, end));
|
|
78
|
+
begin = end;
|
|
79
|
+
}
|
|
80
|
+
const first = request.startLine - 1;
|
|
81
|
+
let content = '';
|
|
82
|
+
let selected = 0;
|
|
83
|
+
let pageBytes = 0;
|
|
84
|
+
for (let index = first; index < lines.length && selected < request.maxLines; index++) {
|
|
85
|
+
const lineBytes = Buffer.byteLength(lines[index], 'utf8');
|
|
86
|
+
if (lineBytes > MAX_PAGE_BYTES)
|
|
87
|
+
throw new ComputerFileError('line_too_long', 'A line exceeds 256 KiB');
|
|
88
|
+
if (pageBytes + lineBytes > MAX_PAGE_BYTES)
|
|
89
|
+
break;
|
|
90
|
+
content += lines[index];
|
|
91
|
+
pageBytes += lineBytes;
|
|
92
|
+
selected++;
|
|
93
|
+
}
|
|
94
|
+
const next = first + selected < lines.length ? request.startLine + selected : undefined;
|
|
95
|
+
return {
|
|
96
|
+
path: request.path, content, startLine: request.startLine,
|
|
97
|
+
endLine: selected ? request.startLine + selected - 1 : 0,
|
|
98
|
+
...(next !== undefined ? { nextLine: next } : {}), truncated: next !== undefined,
|
|
99
|
+
sizeBytes: bytes.length, sha256: hash(bytes),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const writeLocks = new Map();
|
|
103
|
+
async function serialWrite(target, action) {
|
|
104
|
+
const previous = writeLocks.get(target) ?? Promise.resolve();
|
|
105
|
+
let release;
|
|
106
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
107
|
+
const queued = previous.then(() => gate);
|
|
108
|
+
writeLocks.set(target, queued);
|
|
109
|
+
await previous;
|
|
110
|
+
try {
|
|
111
|
+
return await action();
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
release();
|
|
115
|
+
if (writeLocks.get(target) === queued)
|
|
116
|
+
writeLocks.delete(target);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validUnicode(content) {
|
|
120
|
+
for (let index = 0; index < content.length; index++) {
|
|
121
|
+
const code = content.charCodeAt(index);
|
|
122
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
123
|
+
const next = content.charCodeAt(++index);
|
|
124
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
export async function writeComputerFile(request, signal, resolvePath = resolveComputerFilePath) {
|
|
133
|
+
if (request.content.includes('\0') || !validUnicode(request.content)) {
|
|
134
|
+
throw new ComputerFileError('invalid_encoding', 'Content must be valid UTF-8 text without NUL');
|
|
135
|
+
}
|
|
136
|
+
const bytes = Buffer.from(request.content, 'utf8');
|
|
137
|
+
if (bytes.length > MAX_WRITE_BYTES)
|
|
138
|
+
throw new ComputerFileError('too_large', 'Content exceeds 1 MiB');
|
|
139
|
+
if (request.mode !== 'create' && request.mode !== 'replace')
|
|
140
|
+
throw new ComputerFileError('invalid_argument', 'Invalid write mode');
|
|
141
|
+
if (request.mode === 'create' ? request.expectedSha256 !== undefined : !/^[0-9a-f]{64}$/.test(request.expectedSha256 ?? '')) {
|
|
142
|
+
throw new ComputerFileError('invalid_argument', 'create forbids expected_sha256; replace requires lowercase SHA-256');
|
|
143
|
+
}
|
|
144
|
+
throwIfAborted(signal);
|
|
145
|
+
const file = await resolvePath(request.root, request.path, request.mode === 'create');
|
|
146
|
+
return serialWrite(file, async () => {
|
|
147
|
+
throwIfAborted(signal);
|
|
148
|
+
await resolvePath(request.root, request.path, request.mode === 'create');
|
|
149
|
+
let mode = 0o666;
|
|
150
|
+
if (request.mode === 'replace') {
|
|
151
|
+
const current = await snapshotFile(file, signal);
|
|
152
|
+
if (hash(current) !== request.expectedSha256)
|
|
153
|
+
throw new ComputerFileError('conflict', 'File changed since it was read');
|
|
154
|
+
try {
|
|
155
|
+
mode = (await stat(file)).mode & 0o777;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw computerFileError(error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const parent = dirname(file);
|
|
162
|
+
const temporary = join(parent, `.${basename(file)}.axom-${randomUUID()}.tmp`);
|
|
163
|
+
let handle;
|
|
164
|
+
try {
|
|
165
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), mode);
|
|
166
|
+
if (request.mode === 'replace' && process.platform !== 'win32')
|
|
167
|
+
await handle.chmod(mode);
|
|
168
|
+
await handle.writeFile(bytes);
|
|
169
|
+
await handle.sync();
|
|
170
|
+
await handle.close();
|
|
171
|
+
handle = undefined;
|
|
172
|
+
throwIfAborted(signal);
|
|
173
|
+
await resolvePath(request.root, request.path, request.mode === 'create');
|
|
174
|
+
if (request.mode === 'create')
|
|
175
|
+
await link(temporary, file);
|
|
176
|
+
else
|
|
177
|
+
await rename(temporary, file);
|
|
178
|
+
return { path: request.path, sizeBytes: bytes.length, sha256: hash(bytes), created: request.mode === 'create' };
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw computerFileError(error);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await handle?.close();
|
|
185
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
186
|
+
throw computerFileError(error); });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
export async function listComputerDir(request, signal, resolvePath = resolveComputerDirPath) {
|
|
191
|
+
if (!Number.isSafeInteger(request.maxEntries) || request.maxEntries < 1 || request.maxEntries > 2000) {
|
|
192
|
+
throw new ComputerFileError('invalid_argument', 'Invalid entry range');
|
|
193
|
+
}
|
|
194
|
+
throwIfAborted(signal);
|
|
195
|
+
const dir = await resolvePath(request.root, request.path);
|
|
196
|
+
throwIfAborted(signal);
|
|
197
|
+
const dirents = (await readdir(dir, { withFileTypes: true }))
|
|
198
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
199
|
+
const entries = [];
|
|
200
|
+
let truncated = false;
|
|
201
|
+
for (const dirent of dirents) {
|
|
202
|
+
if (request.startAfter !== undefined && !(dirent.name > request.startAfter))
|
|
203
|
+
continue;
|
|
204
|
+
if (entries.length >= request.maxEntries) {
|
|
205
|
+
truncated = true;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
let kind;
|
|
209
|
+
if (dirent.isFile())
|
|
210
|
+
kind = 'file';
|
|
211
|
+
else if (dirent.isDirectory())
|
|
212
|
+
kind = 'directory';
|
|
213
|
+
else if (dirent.isSymbolicLink())
|
|
214
|
+
kind = 'symlink';
|
|
215
|
+
else {
|
|
216
|
+
// Filesystems that report DT_UNKNOWN leave the kind to a fresh lstat.
|
|
217
|
+
try {
|
|
218
|
+
const entry = await lstat(join(dir, dirent.name));
|
|
219
|
+
kind = entry.isFile() ? 'file' : entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'other';
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error.code !== 'ENOENT')
|
|
223
|
+
throw computerFileError(error);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const entry = { name: dirent.name, kind };
|
|
228
|
+
if (kind === 'file') {
|
|
229
|
+
try {
|
|
230
|
+
entry.sizeBytes = (await lstat(join(dir, dirent.name))).size;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// A file that vanished between readdir and lstat simply drops out of the snapshot.
|
|
234
|
+
if (error.code !== 'ENOENT')
|
|
235
|
+
throw computerFileError(error);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
entries.push(entry);
|
|
240
|
+
}
|
|
241
|
+
const nextAfter = truncated ? entries[entries.length - 1]?.name : undefined;
|
|
242
|
+
return { path: request.path, entries, truncated, ...(nextAfter !== undefined ? { nextAfter } : {}) };
|
|
243
|
+
}
|
|
244
|
+
export async function createComputerDir(request, signal, resolvePlan = resolveComputerDirCreatePath) {
|
|
245
|
+
throwIfAborted(signal);
|
|
246
|
+
const plan = await resolvePlan(request.root, request.path, request.parents);
|
|
247
|
+
if (plan.missing.length === 0) {
|
|
248
|
+
if (!request.parents)
|
|
249
|
+
throw new ComputerFileError('conflict', 'Target already exists');
|
|
250
|
+
return { path: request.path, created: false };
|
|
251
|
+
}
|
|
252
|
+
if (!request.parents && plan.missing.length > 1) {
|
|
253
|
+
throw new ComputerFileError('not_found', 'Parent directory not found');
|
|
254
|
+
}
|
|
255
|
+
let cursor = plan.anchor;
|
|
256
|
+
let created = false;
|
|
257
|
+
for (const component of plan.missing) {
|
|
258
|
+
throwIfAborted(signal);
|
|
259
|
+
const target = join(cursor, component);
|
|
260
|
+
try {
|
|
261
|
+
await mkdir(target);
|
|
262
|
+
created = true;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error.code !== 'EEXIST')
|
|
266
|
+
throw computerFileError(error);
|
|
267
|
+
if (!request.parents)
|
|
268
|
+
throw new ComputerFileError('conflict', 'Target already exists');
|
|
269
|
+
}
|
|
270
|
+
// A component we just made (or found racing us) must be a real directory, never a symlink stand-in.
|
|
271
|
+
const entry = await lstat(target).catch(error => { throw computerFileError(error); });
|
|
272
|
+
if (entry.isSymbolicLink() || !entry.isDirectory())
|
|
273
|
+
throw new ComputerFileError('conflict', 'Path is not a directory');
|
|
274
|
+
cursor = target;
|
|
275
|
+
}
|
|
276
|
+
return { path: request.path, created };
|
|
277
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
export class ComputerFileError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = 'ComputerFileError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function computerFileError(error) {
|
|
12
|
+
if (error instanceof ComputerFileError)
|
|
13
|
+
return error;
|
|
14
|
+
const code = error?.code;
|
|
15
|
+
if (code === 'ENOENT')
|
|
16
|
+
return new ComputerFileError('not_found', 'File or parent directory not found');
|
|
17
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
18
|
+
return new ComputerFileError('permission_denied', 'File access denied');
|
|
19
|
+
if (code === 'EEXIST')
|
|
20
|
+
return new ComputerFileError('conflict', 'Target already exists');
|
|
21
|
+
if (code === 'ELOOP')
|
|
22
|
+
return new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
23
|
+
return new ComputerFileError('io_error', 'File operation failed');
|
|
24
|
+
}
|
|
25
|
+
/** Validate portable slash components before touching the filesystem. */
|
|
26
|
+
export function validateComputerFilePath(path) {
|
|
27
|
+
if (!path || path.includes('\\') || path.includes(':') || path.includes('\0') || isAbsolute(path)) {
|
|
28
|
+
throw new ComputerFileError('invalid_path', 'Path must be relative to the selected workspace');
|
|
29
|
+
}
|
|
30
|
+
const components = path.split('/');
|
|
31
|
+
if (components.some(component => !component || component === '.' || component === '..')) {
|
|
32
|
+
throw new ComputerFileError('invalid_path', 'Path contains an unsafe component');
|
|
33
|
+
}
|
|
34
|
+
return components;
|
|
35
|
+
}
|
|
36
|
+
/** The configured root may itself be a symlink; none of its descendants may be. */
|
|
37
|
+
export async function resolveComputerFilePath(root, path, allowMissingLeaf) {
|
|
38
|
+
const components = validateComputerFilePath(path);
|
|
39
|
+
let cursor;
|
|
40
|
+
try {
|
|
41
|
+
cursor = await realpath(root);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw computerFileError(error);
|
|
45
|
+
}
|
|
46
|
+
for (let index = 0; index < components.length; index++) {
|
|
47
|
+
cursor = join(cursor, components[index]);
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = await lstat(cursor);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT' && allowMissingLeaf && index === components.length - 1)
|
|
54
|
+
return cursor;
|
|
55
|
+
throw computerFileError(error);
|
|
56
|
+
}
|
|
57
|
+
if (stat.isSymbolicLink())
|
|
58
|
+
throw new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
59
|
+
if (index === components.length - 1) {
|
|
60
|
+
if (!stat.isFile())
|
|
61
|
+
throw new ComputerFileError('not_regular_file', 'Target is not a regular file');
|
|
62
|
+
}
|
|
63
|
+
else if (!stat.isDirectory()) {
|
|
64
|
+
throw new ComputerFileError('not_regular_file', 'Parent is not a directory');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return cursor;
|
|
68
|
+
}
|
|
69
|
+
/** Resolve only a leaf relative to an already pinned process cwd; never turn it back into an absolute pathname. */
|
|
70
|
+
export async function resolvePinnedLeafPath(_root, path, allowMissingLeaf) {
|
|
71
|
+
const components = validateComputerFilePath(path);
|
|
72
|
+
if (components.length !== 1)
|
|
73
|
+
throw new ComputerFileError('invalid_path', 'Pinned file operation requires one path component');
|
|
74
|
+
try {
|
|
75
|
+
const entry = await lstat(path);
|
|
76
|
+
if (entry.isSymbolicLink())
|
|
77
|
+
throw new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
78
|
+
if (!entry.isFile())
|
|
79
|
+
throw new ComputerFileError('not_regular_file', 'Target is not a regular file');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== 'ENOENT' || !allowMissingLeaf)
|
|
83
|
+
throw computerFileError(error);
|
|
84
|
+
}
|
|
85
|
+
return path;
|
|
86
|
+
}
|
|
87
|
+
/** Every component of a directory listing target must exist, be a directory, and not be a symlink; '' is the root. */
|
|
88
|
+
export async function resolveComputerDirPath(root, path) {
|
|
89
|
+
const components = path === '' ? [] : validateComputerFilePath(path);
|
|
90
|
+
let cursor;
|
|
91
|
+
try {
|
|
92
|
+
cursor = await realpath(root);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw computerFileError(error);
|
|
96
|
+
}
|
|
97
|
+
for (const component of components) {
|
|
98
|
+
cursor = join(cursor, component);
|
|
99
|
+
let entry;
|
|
100
|
+
try {
|
|
101
|
+
entry = await lstat(cursor);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
throw computerFileError(error);
|
|
105
|
+
}
|
|
106
|
+
if (entry.isSymbolicLink())
|
|
107
|
+
throw new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
108
|
+
if (!entry.isDirectory())
|
|
109
|
+
throw new ComputerFileError('not_regular_file', 'Target is not a directory');
|
|
110
|
+
}
|
|
111
|
+
return cursor;
|
|
112
|
+
}
|
|
113
|
+
/** Pinned variant for the file worker: '' lists the pinned cwd itself, otherwise exactly one component. */
|
|
114
|
+
export async function resolvePinnedDirPath(_root, path) {
|
|
115
|
+
const components = path === '' ? [] : validateComputerFilePath(path);
|
|
116
|
+
if (components.length > 1)
|
|
117
|
+
throw new ComputerFileError('invalid_path', 'Pinned directory operation requires at most one path component');
|
|
118
|
+
const target = path === '' ? '.' : join('.', path);
|
|
119
|
+
let entry;
|
|
120
|
+
try {
|
|
121
|
+
entry = await lstat(target);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw computerFileError(error);
|
|
125
|
+
}
|
|
126
|
+
if (entry.isSymbolicLink())
|
|
127
|
+
throw new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
128
|
+
if (!entry.isDirectory())
|
|
129
|
+
throw new ComputerFileError('not_regular_file', 'Target is not a directory');
|
|
130
|
+
return target;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Locate the deepest existing ancestor for a directory creation. Without parents the anchor must be the
|
|
134
|
+
* target's own parent (only the leaf may be missing); with parents any prefix may be missing. Existing
|
|
135
|
+
* components must be directories and never symlinks; an existing leaf counts as "already created".
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveComputerDirCreatePath(root, path, parents) {
|
|
138
|
+
const components = validateComputerFilePath(path);
|
|
139
|
+
let cursor;
|
|
140
|
+
try {
|
|
141
|
+
cursor = await realpath(root);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw computerFileError(error);
|
|
145
|
+
}
|
|
146
|
+
let anchorIndex = 0;
|
|
147
|
+
for (; anchorIndex < components.length; anchorIndex++) {
|
|
148
|
+
const candidate = join(cursor, components[anchorIndex]);
|
|
149
|
+
let entry;
|
|
150
|
+
try {
|
|
151
|
+
entry = await lstat(candidate);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code !== 'ENOENT')
|
|
155
|
+
throw computerFileError(error);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (entry.isSymbolicLink())
|
|
159
|
+
throw new ComputerFileError('invalid_path', 'Symbolic links are not allowed');
|
|
160
|
+
if (!entry.isDirectory())
|
|
161
|
+
throw new ComputerFileError('not_found', 'Path is not a directory');
|
|
162
|
+
cursor = candidate;
|
|
163
|
+
}
|
|
164
|
+
const missing = components.slice(anchorIndex);
|
|
165
|
+
if (!parents && missing.length > 1) {
|
|
166
|
+
throw new ComputerFileError('not_found', 'Parent directory not found');
|
|
167
|
+
}
|
|
168
|
+
return { anchor: cursor, missing };
|
|
169
|
+
}
|
|
170
|
+
/** Pinned variant for the file worker: create the missing components below the pinned cwd, one leaf at a time. */
|
|
171
|
+
export async function resolvePinnedDirCreatePath(_root, path, _parents) {
|
|
172
|
+
const components = path === '' ? [] : validateComputerFilePath(path);
|
|
173
|
+
return { anchor: '.', missing: components };
|
|
174
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
3
|
+
import { computerFileError, ComputerFileError, resolvePinnedDirCreatePath, resolvePinnedDirPath, resolvePinnedLeafPath, } from './filePaths.js';
|
|
4
|
+
import { createComputerDir, listComputerDir, readComputerFile, writeComputerFile, } from './fileOps.js';
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
let started = false;
|
|
7
|
+
let pending;
|
|
8
|
+
/**
|
|
9
|
+
* This worker is a separate process so its cwd is a kernel-pinned directory reference, not a
|
|
10
|
+
* pathname re-resolved after validation. A swapped ancestor symlink may affect process launch,
|
|
11
|
+
* but the post-launch realpath check then rejects it before touching the leaf. Directory
|
|
12
|
+
* operations accept at most one pinned component: multi-component `parents` creation walks
|
|
13
|
+
* one leaf at a time from the pinned anchor instead of re-resolving full pathnames.
|
|
14
|
+
*/
|
|
15
|
+
process.on('message', (message) => {
|
|
16
|
+
if (message.kind === 'cancel') {
|
|
17
|
+
controller.abort();
|
|
18
|
+
if (pending) {
|
|
19
|
+
pending = undefined;
|
|
20
|
+
process.disconnect();
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (message.kind === 'continue') {
|
|
25
|
+
const work = pending;
|
|
26
|
+
if (!work)
|
|
27
|
+
return;
|
|
28
|
+
pending = undefined;
|
|
29
|
+
void (async () => {
|
|
30
|
+
try {
|
|
31
|
+
if (work.kind === 'read') {
|
|
32
|
+
const request = { ...work.request, root: '.' };
|
|
33
|
+
const data = await readComputerFile(request, controller.signal, resolvePinnedLeafPath);
|
|
34
|
+
process.send?.({ ok: true, data }, () => process.disconnect());
|
|
35
|
+
}
|
|
36
|
+
else if (work.kind === 'write') {
|
|
37
|
+
const request = { ...work.request, root: '.' };
|
|
38
|
+
const data = await writeComputerFile(request, controller.signal, resolvePinnedLeafPath);
|
|
39
|
+
process.send?.({ ok: true, data }, () => process.disconnect());
|
|
40
|
+
}
|
|
41
|
+
else if (work.kind === 'ls') {
|
|
42
|
+
const request = { ...work.request, root: '.' };
|
|
43
|
+
const data = await listComputerDir(request, controller.signal, resolvePinnedDirPath);
|
|
44
|
+
process.send?.({ ok: true, data }, () => process.disconnect());
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const request = { ...work.request, root: '.' };
|
|
48
|
+
const data = await createComputerDir(request, controller.signal, resolvePinnedDirCreatePath);
|
|
49
|
+
process.send?.({ ok: true, data }, () => process.disconnect());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const mapped = computerFileError(error);
|
|
54
|
+
process.send?.({ ok: false, error: { code: mapped.code, message: mapped.message } }, () => process.disconnect());
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (started)
|
|
60
|
+
return;
|
|
61
|
+
started = true;
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const parent = await realpath('.');
|
|
65
|
+
const rel = relative(message.root, parent);
|
|
66
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
67
|
+
throw new ComputerFileError('invalid_path', 'File parent escaped the configured workspace');
|
|
68
|
+
}
|
|
69
|
+
if (controller.signal.aborted) {
|
|
70
|
+
process.disconnect();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
pending = message;
|
|
74
|
+
process.send?.({ kind: 'ready' });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const mapped = computerFileError(error);
|
|
78
|
+
process.send?.({ ok: false, error: { code: mapped.code, message: mapped.message } }, () => process.disconnect());
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { realpath } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ComputerFileError, computerFileError, resolveComputerDirCreatePath, resolveComputerDirPath, resolveComputerFilePath } from './filePaths.js';
|
|
7
|
+
const workerUrl = existsSync(new URL('./fileWorker.js', import.meta.url))
|
|
8
|
+
? new URL('./fileWorker.js', import.meta.url)
|
|
9
|
+
: new URL('../../dist/workstation/fileWorker.js', import.meta.url);
|
|
10
|
+
const writeLocks = new Map();
|
|
11
|
+
async function serialWrite(target, action) {
|
|
12
|
+
const previous = writeLocks.get(target) ?? Promise.resolve();
|
|
13
|
+
let release;
|
|
14
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
15
|
+
const queued = previous.then(() => gate);
|
|
16
|
+
writeLocks.set(target, queued);
|
|
17
|
+
await previous;
|
|
18
|
+
try {
|
|
19
|
+
return await action();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
release();
|
|
23
|
+
if (writeLocks.get(target) === queued)
|
|
24
|
+
writeLocks.delete(target);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function execute(kind, request, anchor, leaf, signal, hooks) {
|
|
28
|
+
if (signal?.aborted)
|
|
29
|
+
throw new ComputerFileError('workstation_offline', 'File operation was cancelled');
|
|
30
|
+
const root = await realpath(request.root).catch(error => { throw computerFileError(error); });
|
|
31
|
+
await hooks?.afterValidation?.();
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = fork(fileURLToPath(workerUrl), [], {
|
|
34
|
+
cwd: anchor, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], execArgv: [],
|
|
35
|
+
});
|
|
36
|
+
let settled = false;
|
|
37
|
+
let killTimer;
|
|
38
|
+
const finish = (error, data) => {
|
|
39
|
+
if (settled)
|
|
40
|
+
return;
|
|
41
|
+
settled = true;
|
|
42
|
+
signal?.removeEventListener('abort', abort);
|
|
43
|
+
if (error)
|
|
44
|
+
reject(error);
|
|
45
|
+
else
|
|
46
|
+
resolve(data);
|
|
47
|
+
};
|
|
48
|
+
const abort = () => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
child.send({ kind: 'cancel' }, () => { });
|
|
52
|
+
killTimer = setTimeout(() => child.kill(), 2_000);
|
|
53
|
+
killTimer.unref();
|
|
54
|
+
finish(new ComputerFileError(kind === 'write' || kind === 'mkdir' ? 'outcome_unknown' : 'workstation_offline', 'File operation was cancelled'));
|
|
55
|
+
};
|
|
56
|
+
child.on('message', (message) => {
|
|
57
|
+
if (typeof message !== 'object' || message === null)
|
|
58
|
+
return;
|
|
59
|
+
const reply = message;
|
|
60
|
+
if (reply.kind === 'ready') {
|
|
61
|
+
void (async () => {
|
|
62
|
+
try {
|
|
63
|
+
await hooks?.afterWorkerReady?.();
|
|
64
|
+
if (!settled)
|
|
65
|
+
child.send({ kind: 'continue' }, error => { if (error)
|
|
66
|
+
finish(computerFileError(error)); });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
child.kill();
|
|
70
|
+
finish(computerFileError(error));
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (reply.ok === true)
|
|
76
|
+
finish(undefined, reply.data);
|
|
77
|
+
else if (reply.ok === false && reply.error)
|
|
78
|
+
finish(new ComputerFileError(reply.error.code, reply.error.message));
|
|
79
|
+
});
|
|
80
|
+
child.once('error', error => finish(computerFileError(error)));
|
|
81
|
+
child.once('exit', () => {
|
|
82
|
+
if (killTimer)
|
|
83
|
+
clearTimeout(killTimer);
|
|
84
|
+
finish(new ComputerFileError(kind === 'write' || kind === 'mkdir' ? 'outcome_unknown' : 'workstation_offline', 'File worker exited without a result'));
|
|
85
|
+
});
|
|
86
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
87
|
+
if (signal?.aborted) {
|
|
88
|
+
abort();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
child.send({ kind, root, request: { ...request, root: '.', path: leaf } }, error => {
|
|
92
|
+
if (error)
|
|
93
|
+
finish(computerFileError(error));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export async function readComputerFileSafely(request, signal, hooks) {
|
|
98
|
+
const root = await realpath(request.root).catch(error => { throw computerFileError(error); });
|
|
99
|
+
const target = await resolveComputerFilePath(root, request.path, false);
|
|
100
|
+
return execute('read', request, dirname(target), basename(target), signal, hooks);
|
|
101
|
+
}
|
|
102
|
+
export async function writeComputerFileSafely(request, signal, hooks) {
|
|
103
|
+
const root = await realpath(request.root).catch(error => { throw computerFileError(error); });
|
|
104
|
+
const target = await resolveComputerFilePath(root, request.path, request.mode === 'create');
|
|
105
|
+
return serialWrite(target, () => execute('write', request, dirname(target), basename(target), signal, hooks));
|
|
106
|
+
}
|
|
107
|
+
export async function listComputerDirSafely(request, signal, hooks) {
|
|
108
|
+
const root = await realpath(request.root).catch(error => { throw computerFileError(error); });
|
|
109
|
+
const target = await resolveComputerDirPath(root, request.path);
|
|
110
|
+
// Listing the workspace root pins the worker at the root itself; '' is the zero-component leaf.
|
|
111
|
+
const anchor = target === root ? root : dirname(target);
|
|
112
|
+
const leaf = target === root ? '' : basename(target);
|
|
113
|
+
const data = await execute('ls', request, anchor, leaf, signal, hooks);
|
|
114
|
+
return { ...data, path: request.path };
|
|
115
|
+
}
|
|
116
|
+
export async function createComputerDirSafely(request, signal, hooks) {
|
|
117
|
+
const root = await realpath(request.root).catch(error => { throw computerFileError(error); });
|
|
118
|
+
const plan = await resolveComputerDirCreatePath(root, request.path, request.parents);
|
|
119
|
+
const data = await execute('mkdir', request, plan.anchor, plan.missing.join('/'), signal, hooks);
|
|
120
|
+
return { ...data, path: request.path };
|
|
121
|
+
}
|