@myagentroam/node 0.11.36 → 0.11.38
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/dist/application/execution-lifecycle.js +54 -0
- package/dist/application/node-lifecycle.js +104 -0
- package/dist/application/node-operation-admission.js +51 -0
- package/dist/application/node-operation-contracts.js +1 -0
- package/dist/application/runner-composition.js +22 -0
- package/dist/application/session-projection-composition.js +26 -0
- package/dist/application/workspace-application.js +61 -0
- package/dist/application/workspace-composition.js +21 -0
- package/dist/capabilities.js +2 -3
- package/dist/codex-app-server.js +1 -471
- package/dist/connection/authorization.js +26 -0
- package/dist/{service → connection}/direct-transfer-service.js +1 -1
- package/dist/connection/endpoints.js +32 -0
- package/dist/connector.js +145 -253
- package/dist/control-outbound-scheduler.js +3 -0
- package/dist/database.js +34 -14
- package/dist/git-askpass.js +1 -1
- package/dist/git-execution-helper.js +1 -1
- package/dist/hosting/hosted-conversation-file-identity.js +27 -0
- package/dist/hosting/hosted-conversation-file-operations.js +44 -0
- package/dist/hosting/hosted-conversation-node-service.js +974 -0
- package/dist/hosting/hosted-conversation-runs.js +599 -0
- package/dist/hosting/hosted-conversation-templates.js +280 -0
- package/dist/hosting/hosted-conversation-uploads.js +344 -0
- package/dist/hosting/hosted-conversation-watch.js +194 -0
- package/dist/hosting/runtime-contracts.js +1 -0
- package/dist/runner/abstract-runner.js +4 -13
- package/dist/{claude-agent-sdk.js → runner/claude/claude-agent-sdk.js} +2 -2
- package/dist/runner/claude/conversation-parser.js +2 -1
- package/dist/runner/claude/managed-run-controller.js +6 -7
- package/dist/runner/claude/message-parsers.js +2 -1
- package/dist/runner/claude/native-transcript.js +2 -1
- package/dist/runner/claude/tool-normalizer.js +1 -1
- package/dist/runner/claude-code-runner.js +2 -2
- package/dist/runner/codex/codex-app-server.js +471 -0
- package/dist/runner/codex/conversation-parser.js +4 -2
- package/dist/runner/codex/managed-run-controller.js +7 -9
- package/dist/runner/codex/native-transcript.js +1 -1
- package/dist/runner/codex/tool-normalizer.js +1 -1
- package/dist/runner/codex-runner.js +9 -27
- package/dist/runner/contracts.js +1 -0
- package/dist/runner/mar-agent/conversation-normalizer.js +2 -2
- package/dist/runner/mar-agent/history-projection.js +1 -1
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +6 -10
- package/dist/runner/mar-agent/managed-run-controller.js +85 -61
- package/dist/runner/mar-agent/runtime-model-factory.js +0 -3
- package/dist/runner/mar-agent-runner.js +1 -1
- package/dist/runner/normalization.js +42 -0
- package/dist/runner/opencode/conversation-parser.js +1 -1
- package/dist/runner/opencode/managed-run-controller.js +6 -7
- package/dist/{opencode-server.js → runner/opencode/opencode-server.js} +2 -2
- package/dist/runner/opencode/tool-normalizer.js +1 -1
- package/dist/runner/opencode-runner.js +4 -5
- package/dist/runner/runner-registry.js +4 -1
- package/dist/runtime-state.js +48 -372
- package/dist/service/computer-use-enablement-service.js +1 -1
- package/dist/service/continuous-run-coordinator.js +52 -7
- package/dist/service/conversation-history-service.js +4 -4
- package/dist/service/conversation-hosting-capability-service.js +1 -1
- package/dist/service/conversation-hosting-enablement-service.js +1 -1
- package/dist/service/conversation-hosting-lifecycle-service.js +3 -3
- package/dist/service/conversation-hosting-maintenance-service.js +1 -1
- package/dist/service/conversation-observation.js +1 -1
- package/dist/service/conversation-segment-service.js +1 -1
- package/dist/service/external-session-resume-service.js +47 -27
- package/dist/service/fake-managed-run-service.js +3 -7
- package/dist/service/git-operation-service.js +1 -1
- package/dist/service/goal-continuation-coordinator.js +14 -7
- package/dist/service/mar-agent-model-transport-test-service.js +1 -1
- package/dist/service/native-session-watch-service.js +1 -1
- package/dist/service/node-connection-lifecycle-service.js +6 -3
- package/dist/service/node-control-channel.js +48 -11
- package/dist/service/node-request-service.js +1 -1
- package/dist/service/node-terminal-channel.js +2 -1
- package/dist/service/node-workspace-coordinator.js +3 -2
- package/dist/service/notification-mcp-bridge.js +6 -2
- package/dist/service/recent-run-projection-service.js +1 -1
- package/dist/service/restart-session-restorer.js +1 -1
- package/dist/service/run-event-service.js +49 -42
- package/dist/service/run-workbench-service.js +3 -3
- package/dist/service/runner-interaction-service.js +6 -5
- package/dist/service/runner-service.js +1 -1
- package/dist/service/session-recent-activity.js +2 -1
- package/dist/service/skill-directory-service.js +1 -1
- package/dist/service/skill-install-service.js +2 -2
- package/dist/service/terminal-service.js +1 -1
- package/dist/service/workbench-event-projection.js +3 -2
- package/dist/service/workbench-event-service.js +1 -1
- package/dist/service/workspace-service.js +3 -2
- package/dist/session/attachments.js +110 -0
- package/dist/session/contracts.js +1 -0
- package/dist/{util → session}/conversation-identity.js +1 -1
- package/dist/session/input-validation.js +15 -0
- package/dist/{service → session}/native-session-projection-service.js +40 -11
- package/dist/session/pagination.js +77 -0
- package/dist/{util → session}/runner-native-session-parsers.js +2 -1
- package/dist/session/runtime/approvals.js +90 -0
- package/dist/session/runtime/conversation-projection.js +310 -0
- package/dist/session/runtime/type-guards.js +19 -0
- package/dist/{service → session}/session-catalog-service.js +5 -4
- package/dist/{service → session}/session-command-service.js +3 -3
- package/dist/{service → session}/session-identity-service.js +1 -1
- package/dist/{service → session}/session-lifecycle-service.js +18 -7
- package/dist/{service → session}/session-message-service.js +6 -5
- package/dist/{service → session}/session-presentation-service.js +1 -1
- package/dist/{service → session}/session-query-service.js +2 -2
- package/dist/{service → session}/session-schedule-service.js +2 -2
- package/dist/util/records.js +3 -0
- package/dist/workspace/contracts.js +1 -0
- package/dist/workspace/file-index.js +534 -0
- package/dist/workspace/files.js +466 -0
- package/dist/workspace/git.js +991 -0
- package/dist/workspace/path-access.js +184 -0
- package/dist/workspace/utf8.js +12 -0
- package/dist/{service → workspace}/workspace-change-service.js +45 -21
- package/dist/{service → workspace}/workspace-code-navigation-service.js +6 -6
- package/dist/{service → workspace}/workspace-file-manager-service.js +1 -1
- package/dist/{service → workspace}/workspace-file-service.js +16 -16
- package/dist/{service → workspace}/workspace-git-exclude-service.js +1 -1
- package/dist/{service → workspace}/workspace-queue-workbench-service.js +3 -2
- package/dist/{service → workspace}/workspace-session-service.js +45 -40
- package/dist/{service → workspace}/workspace-upload-service.js +28 -28
- package/dist/{service → workspace}/workspace-watch-service.js +29 -28
- package/dist/{service → workspace}/workspace-workbench-service.js +4 -3
- package/package.json +4 -4
- package/dist/service/hosted-conversation-node-service.js +0 -2252
- package/dist/service/run-domain-state.js +0 -4
- package/dist/service/session-domain-state.js +0 -9
- package/dist/service/workspace-domain-state.js +0 -41
- package/dist/util/node-operation-parsers.js +0 -320
- package/dist/workspace.js +0 -2168
- /package/dist/{connector → application}/node-connector-options.js +0 -0
- /package/dist/{util → connection}/node-operation-data.js +0 -0
- /package/dist/{connector → connection}/node-operation-router.js +0 -0
- /package/dist/{service → hosting}/hosted-conversation-sandbox-profile.js +0 -0
- /package/dist/{service → hosting}/hosted-conversation-store.js +0 -0
- /package/dist/{util → mcp}/secret-environment.js +0 -0
- /package/dist/{claude-channel.js → runner/claude/claude-channel.js} +0 -0
- /package/dist/{util → runner}/personal-instructions.js +0 -0
- /package/dist/{util → runner}/runner-error.js +0 -0
- /package/dist/{service/conversation-work-process.js → session/runtime/work-process.js} +0 -0
- /package/dist/{service → session}/session-context-service.js +0 -0
- /package/dist/{util → workspace}/git-credential-client.js +0 -0
- /package/dist/{service → workspace}/workspace-content-search-service.js +0 -0
- /package/dist/{service → workspace}/workspace-watch-snapshot-service.js +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { isUtf8ContinuationByte, isValidUtf8 } from './utf8.js';
|
|
2
|
+
import { assertWorkspaceMutationPathHasNoSymlinks, isWithinAllowedRoot, resolveNewWorkspaceEntry, resolveRegisteredWorkspaceRoot, resolveWorkspaceEntry, resolveWorkspaceEntryFromCanonicalRoot, resolveWorkspaceEntryMetadata } from './path-access.js';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
|
|
7
|
+
const FILE_READ_LIMIT = 512 * 1024;
|
|
8
|
+
const DIRECTORY_PAGE_LIMIT = 200;
|
|
9
|
+
const DIRECTORY_PICKER_LIMIT = 200;
|
|
10
|
+
export async function createWorkspaceDirectory(parentPath, directoryName, allowedRoots) {
|
|
11
|
+
const name = directoryName.trim();
|
|
12
|
+
if (name.length === 0 ||
|
|
13
|
+
name.length > 255 ||
|
|
14
|
+
name === '.' ||
|
|
15
|
+
name === '..' ||
|
|
16
|
+
name.includes('/') ||
|
|
17
|
+
name.includes('\\') ||
|
|
18
|
+
name.includes('\0'))
|
|
19
|
+
throw new Error('WORKSPACE_DIRECTORY_NAME_INVALID');
|
|
20
|
+
const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
|
|
21
|
+
const parent = await realpath(parentPath).catch(() => {
|
|
22
|
+
throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
|
|
23
|
+
});
|
|
24
|
+
const metadata = await stat(parent).catch(() => undefined);
|
|
25
|
+
if (metadata?.isDirectory() !== true || !isWithinAllowedRoot(parent, roots))
|
|
26
|
+
throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
|
|
27
|
+
const target = resolve(parent, name);
|
|
28
|
+
if (dirname(target) !== parent || !isWithinAllowedRoot(target, roots))
|
|
29
|
+
throw new Error('WORKSPACE_DIRECTORY_NAME_INVALID');
|
|
30
|
+
try {
|
|
31
|
+
await mkdir(target);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === 'EEXIST')
|
|
35
|
+
throw new Error('WORKSPACE_DIRECTORY_EXISTS');
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return target;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Lists directory names only for the Workspace picker. Browsing begins at the
|
|
42
|
+
* first allowed root when configured, while the Node account home remains a
|
|
43
|
+
* browse boundary and terminal default.
|
|
44
|
+
*/
|
|
45
|
+
export async function listWorkspaceDirectories(requestedPath, allowedRoots, homePath = homedir()) {
|
|
46
|
+
const home = await realpath(homePath);
|
|
47
|
+
const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
|
|
48
|
+
const browseRoots = [...new Set([home, ...roots])];
|
|
49
|
+
const requested = requestedPath === undefined ? (roots[0] ?? home) : await realpath(requestedPath);
|
|
50
|
+
const directory = await stat(requested);
|
|
51
|
+
if (!directory.isDirectory() || !isWithinAllowedRoot(requested, browseRoots))
|
|
52
|
+
throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
|
|
53
|
+
const children = await readdir(requested, { withFileTypes: true });
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
56
|
+
if (!child.isDirectory() && !child.isSymbolicLink())
|
|
57
|
+
continue;
|
|
58
|
+
const path = await realpath(resolve(requested, child.name)).catch(() => undefined);
|
|
59
|
+
if (path === undefined || !isWithinAllowedRoot(path, browseRoots))
|
|
60
|
+
continue;
|
|
61
|
+
const metadata = await stat(path).catch(() => undefined);
|
|
62
|
+
if (metadata === undefined || !metadata.isDirectory())
|
|
63
|
+
continue;
|
|
64
|
+
entries.push({ path, name: child.name, selectable: isWithinAllowedRoot(path, roots) });
|
|
65
|
+
if (entries.length > DIRECTORY_PICKER_LIMIT)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(requested);
|
|
69
|
+
return {
|
|
70
|
+
homePath: home,
|
|
71
|
+
currentPath: requested,
|
|
72
|
+
parentPath: parent === requested || !isWithinAllowedRoot(parent, browseRoots) ? null : parent,
|
|
73
|
+
browseRoots: roots,
|
|
74
|
+
selectable: isWithinAllowedRoot(requested, roots),
|
|
75
|
+
entries: entries.slice(0, DIRECTORY_PICKER_LIMIT),
|
|
76
|
+
truncated: entries.length > DIRECTORY_PICKER_LIMIT
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT) {
|
|
80
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > DIRECTORY_PAGE_LIMIT)
|
|
81
|
+
throw new Error('DIRECTORY_CURSOR_INVALID');
|
|
82
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
83
|
+
const directory = await resolveWorkspaceEntryFromCanonicalRoot(root, requestedPath, true);
|
|
84
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
85
|
+
const cursorValue = decodeDirectoryCursor(cursor, requestedPath);
|
|
86
|
+
const names = entries
|
|
87
|
+
.filter((entry) => entry.name !== '.git' && !isWorkspaceUploadTemporaryName(entry.name))
|
|
88
|
+
.map((entry) => entry.name)
|
|
89
|
+
.sort((left, right) => left.localeCompare(right));
|
|
90
|
+
const start = cursorValue === undefined ? 0 : names.findIndex((name) => name > cursorValue);
|
|
91
|
+
if (cursorValue !== undefined && start === -1)
|
|
92
|
+
return { entries: [], nextCursor: null, truncated: false };
|
|
93
|
+
const pageNames = names.slice(start, start + limit);
|
|
94
|
+
const output = [];
|
|
95
|
+
for (const name of pageNames) {
|
|
96
|
+
const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false).catch((error) => {
|
|
97
|
+
if (isSkippableWorkspaceEntryError(error))
|
|
98
|
+
return undefined;
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
if (child === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
const metadata = await lstat(child).catch((error) => {
|
|
104
|
+
if (error.code === 'ENOENT')
|
|
105
|
+
return undefined;
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
|
|
109
|
+
continue;
|
|
110
|
+
output.push({
|
|
111
|
+
path: relative(root, child).split(sep).join('/'),
|
|
112
|
+
name,
|
|
113
|
+
kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
|
|
114
|
+
size: metadata.isFile() ? metadata.size : null,
|
|
115
|
+
modifiedAt: metadata.mtimeMs
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const nextName = names[start + pageNames.length];
|
|
119
|
+
return {
|
|
120
|
+
entries: output,
|
|
121
|
+
nextCursor: nextName === undefined || pageNames.length === 0
|
|
122
|
+
? null
|
|
123
|
+
: encodeDirectoryCursor(requestedPath, pageNames[pageNames.length - 1]),
|
|
124
|
+
truncated: nextName !== undefined
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function mutateWorkspaceFile(workspaceRoot, mutation) {
|
|
128
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
129
|
+
if (mutation.action === 'CREATE_FILE' || mutation.action === 'CREATE_DIRECTORY') {
|
|
130
|
+
const target = await resolveNewWorkspaceEntry(root, mutation.path);
|
|
131
|
+
if (mutation.action === 'CREATE_FILE')
|
|
132
|
+
await writeFile(target, '', { flag: 'wx' });
|
|
133
|
+
else
|
|
134
|
+
await mkdir(target);
|
|
135
|
+
return { path: workspaceRelativePath(root, target) };
|
|
136
|
+
}
|
|
137
|
+
await assertWorkspaceMutationPathHasNoSymlinks(root, mutation.path);
|
|
138
|
+
const source = await resolveWorkspaceEntryMetadata(root, mutation.path);
|
|
139
|
+
if (workspaceRelativePath(root, source.path) === '.')
|
|
140
|
+
throw new Error('FILE_ROOT_MUTATION_INVALID');
|
|
141
|
+
if (mutation.action === 'DELETE') {
|
|
142
|
+
await rm(source.path, { recursive: source.metadata.isDirectory(), force: false });
|
|
143
|
+
return { deleted: true };
|
|
144
|
+
}
|
|
145
|
+
const destination = await resolveNewWorkspaceEntry(root, mutation.destination);
|
|
146
|
+
if (source.metadata.isDirectory()) {
|
|
147
|
+
const destinationFromSource = relative(source.path, destination);
|
|
148
|
+
if (destinationFromSource === '' ||
|
|
149
|
+
(!isAbsolute(destinationFromSource) &&
|
|
150
|
+
destinationFromSource !== '..' &&
|
|
151
|
+
!destinationFromSource.startsWith(`..${sep}`)))
|
|
152
|
+
throw new Error('FILE_MOVE_DESCENDANT_INVALID');
|
|
153
|
+
}
|
|
154
|
+
await rename(source.path, destination);
|
|
155
|
+
return { path: workspaceRelativePath(root, destination) };
|
|
156
|
+
}
|
|
157
|
+
function workspaceRelativePath(root, path) {
|
|
158
|
+
const value = relative(root, path).split(sep).join('/');
|
|
159
|
+
return value.length === 0 ? '.' : value;
|
|
160
|
+
}
|
|
161
|
+
function isSkippableWorkspaceEntryError(error) {
|
|
162
|
+
return (error instanceof Error &&
|
|
163
|
+
(error.message === 'FILE_PATH_ESCAPE' ||
|
|
164
|
+
error.message === 'FILE_NOT_FOUND' ||
|
|
165
|
+
error.message === 'FILE_NOT_REGULAR'));
|
|
166
|
+
}
|
|
167
|
+
/** Validates an upload destination without following an existing target symlink. */
|
|
168
|
+
export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name) {
|
|
169
|
+
if (name.length === 0 ||
|
|
170
|
+
name === '.' ||
|
|
171
|
+
name === '..' ||
|
|
172
|
+
name.includes('\0') ||
|
|
173
|
+
name.includes('/') ||
|
|
174
|
+
name.includes('\\') ||
|
|
175
|
+
isWorkspaceUploadTemporaryName(name)) {
|
|
176
|
+
return { path: '', absolutePath: '', status: 'INVALID_NAME' };
|
|
177
|
+
}
|
|
178
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
179
|
+
const directory = await resolveWorkspaceEntry(root, requestedDirectory, true);
|
|
180
|
+
const absolutePath = resolve(directory, name);
|
|
181
|
+
if (dirname(absolutePath) !== directory)
|
|
182
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
183
|
+
const relativePath = relative(root, absolutePath).split(sep).join('/');
|
|
184
|
+
const metadata = await lstat(absolutePath).catch((error) => {
|
|
185
|
+
if (error.code === 'ENOENT')
|
|
186
|
+
return undefined;
|
|
187
|
+
throw error;
|
|
188
|
+
});
|
|
189
|
+
if (metadata === undefined)
|
|
190
|
+
return { path: relativePath, absolutePath, status: 'READY' };
|
|
191
|
+
if (metadata.isSymbolicLink())
|
|
192
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
193
|
+
if (metadata.isFile())
|
|
194
|
+
return { path: relativePath, absolutePath, status: 'EXISTS_FILE' };
|
|
195
|
+
return { path: relativePath, absolutePath, status: 'EXISTS_DIRECTORY' };
|
|
196
|
+
}
|
|
197
|
+
export function workspaceUploadTemporaryName(uploadId) {
|
|
198
|
+
return `.mar-upload-${uploadId}.part`;
|
|
199
|
+
}
|
|
200
|
+
function isWorkspaceUploadTemporaryName(name) {
|
|
201
|
+
return (/^\.mar-upload-[0-9a-f-]{36}\.part$/iu.test(name) ||
|
|
202
|
+
/^\.mar-edit-[0-9a-f-]{36}\.part$/iu.test(name));
|
|
203
|
+
}
|
|
204
|
+
export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
|
|
205
|
+
if (!Number.isSafeInteger(offset) ||
|
|
206
|
+
offset < 0 ||
|
|
207
|
+
!Number.isSafeInteger(limit) ||
|
|
208
|
+
limit < 1 ||
|
|
209
|
+
limit > FILE_READ_LIMIT)
|
|
210
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
211
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
212
|
+
const file = await resolveWorkspaceEntry(root, requestedPath, false);
|
|
213
|
+
const metadata = await lstat(file);
|
|
214
|
+
if (!metadata.isFile())
|
|
215
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
216
|
+
if (metadata.size > FILE_READ_LIMIT * 16)
|
|
217
|
+
throw new Error('FILE_TOO_LARGE');
|
|
218
|
+
const source = await readFile(file);
|
|
219
|
+
const revision = createHash('sha256').update(source).digest('hex');
|
|
220
|
+
const binary = source.includes(0) || !isValidUtf8(source);
|
|
221
|
+
if (binary) {
|
|
222
|
+
const end = Math.min(source.length, offset + limit);
|
|
223
|
+
const slice = source.subarray(offset, end);
|
|
224
|
+
return {
|
|
225
|
+
path: relative(root, file).split(sep).join('/'),
|
|
226
|
+
encoding: 'base64',
|
|
227
|
+
size: source.length,
|
|
228
|
+
content: slice.toString('base64'),
|
|
229
|
+
truncated: end < source.length,
|
|
230
|
+
nextOffset: end < source.length ? end : null,
|
|
231
|
+
revision
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
|
|
235
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
236
|
+
let end = Math.min(source.length, offset + limit);
|
|
237
|
+
if (end < source.length && isUtf8ContinuationByte(source[end])) {
|
|
238
|
+
while (end > offset && isUtf8ContinuationByte(source[end]))
|
|
239
|
+
end -= 1;
|
|
240
|
+
if (end === offset) {
|
|
241
|
+
end += 1;
|
|
242
|
+
while (end < source.length && isUtf8ContinuationByte(source[end]))
|
|
243
|
+
end += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const slice = source.subarray(offset, end);
|
|
247
|
+
const content = new TextDecoder('utf-8', { fatal: true }).decode(slice);
|
|
248
|
+
const nextOffset = end < source.length ? end : null;
|
|
249
|
+
return {
|
|
250
|
+
path: relative(root, file).split(sep).join('/'),
|
|
251
|
+
encoding: 'utf-8',
|
|
252
|
+
size: source.length,
|
|
253
|
+
content,
|
|
254
|
+
truncated: nextOffset !== null,
|
|
255
|
+
nextOffset,
|
|
256
|
+
revision
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function writeWorkspaceTextFile(workspaceRoot, requestedPath, content, expectedRevision) {
|
|
260
|
+
if (!/^[0-9a-f]{64}$/u.test(expectedRevision))
|
|
261
|
+
throw new Error('FILE_REVISION_INVALID');
|
|
262
|
+
const source = Buffer.from(content, 'utf8');
|
|
263
|
+
if (source.length > FILE_READ_LIMIT * 16)
|
|
264
|
+
throw new Error('FILE_TOO_LARGE');
|
|
265
|
+
if (source.includes(0))
|
|
266
|
+
throw new Error('FILE_NOT_TEXT');
|
|
267
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
268
|
+
await assertWorkspaceMutationPathHasNoSymlinks(root, requestedPath);
|
|
269
|
+
const file = await resolveWorkspaceEntry(root, requestedPath, false);
|
|
270
|
+
const metadata = await lstat(file);
|
|
271
|
+
if (!metadata.isFile())
|
|
272
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
273
|
+
const current = await readFile(file);
|
|
274
|
+
if (current.includes(0) || !isValidUtf8(current))
|
|
275
|
+
throw new Error('FILE_NOT_TEXT');
|
|
276
|
+
const currentRevision = createHash('sha256').update(current).digest('hex');
|
|
277
|
+
if (currentRevision !== expectedRevision)
|
|
278
|
+
throw new Error('FILE_WRITE_CONFLICT');
|
|
279
|
+
const revision = createHash('sha256').update(source).digest('hex');
|
|
280
|
+
const staging = resolve(dirname(file), `.mar-edit-${randomUUID()}.part`);
|
|
281
|
+
try {
|
|
282
|
+
await writeFile(staging, source, { flag: 'wx', mode: metadata.mode });
|
|
283
|
+
await rename(staging, file);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await rm(staging, { force: true }).catch(() => undefined);
|
|
287
|
+
}
|
|
288
|
+
return { path: workspaceRelativePath(root, file), revision, size: source.length };
|
|
289
|
+
}
|
|
290
|
+
export async function readWorkspaceFileContentRange(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
|
|
291
|
+
if (!Number.isSafeInteger(offset) ||
|
|
292
|
+
offset < 0 ||
|
|
293
|
+
!Number.isSafeInteger(limit) ||
|
|
294
|
+
limit < 1 ||
|
|
295
|
+
limit > FILE_READ_LIMIT)
|
|
296
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
297
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
298
|
+
const file = await resolveWorkspaceEntry(root, requestedPath, false);
|
|
299
|
+
const metadata = await lstat(file);
|
|
300
|
+
if (!metadata.isFile())
|
|
301
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
302
|
+
if (offset > metadata.size)
|
|
303
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
304
|
+
const length = Math.min(limit, metadata.size - offset);
|
|
305
|
+
const source = Buffer.allocUnsafe(length);
|
|
306
|
+
const handle = await open(file, 'r');
|
|
307
|
+
try {
|
|
308
|
+
const { bytesRead } = await handle.read(source, 0, length, offset);
|
|
309
|
+
const end = offset + bytesRead;
|
|
310
|
+
return {
|
|
311
|
+
path: relative(root, file).split(sep).join('/'),
|
|
312
|
+
size: metadata.size,
|
|
313
|
+
sourceRevision: fileSourceRevision(metadata),
|
|
314
|
+
offset,
|
|
315
|
+
contentBase64: source.subarray(0, bytesRead).toString('base64'),
|
|
316
|
+
nextOffset: end < metadata.size ? end : null
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await handle.close();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Reads one regular absolute file range using the Node account's filesystem permissions. */
|
|
324
|
+
export async function readAbsoluteFileContentRange(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
|
|
325
|
+
if (!Number.isSafeInteger(offset) ||
|
|
326
|
+
offset < 0 ||
|
|
327
|
+
!Number.isSafeInteger(limit) ||
|
|
328
|
+
limit < 1 ||
|
|
329
|
+
limit > FILE_READ_LIMIT)
|
|
330
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
331
|
+
const { file, metadata } = await resolveAbsoluteFile(requestedPath);
|
|
332
|
+
if (offset > metadata.size)
|
|
333
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
334
|
+
const length = Math.min(limit, metadata.size - offset);
|
|
335
|
+
const source = Buffer.allocUnsafe(length);
|
|
336
|
+
const handle = await open(file, 'r');
|
|
337
|
+
try {
|
|
338
|
+
const { bytesRead } = await handle.read(source, 0, length, offset);
|
|
339
|
+
const end = offset + bytesRead;
|
|
340
|
+
return {
|
|
341
|
+
path: file,
|
|
342
|
+
size: metadata.size,
|
|
343
|
+
sourceRevision: fileSourceRevision(metadata),
|
|
344
|
+
offset,
|
|
345
|
+
contentBase64: source.subarray(0, bytesRead).toString('base64'),
|
|
346
|
+
nextOffset: end < metadata.size ? end : null
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await handle.close();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function fileSourceRevision(metadata) {
|
|
354
|
+
return createHash('sha256')
|
|
355
|
+
.update([metadata.dev, metadata.ino, metadata.size, metadata.mtimeMs, metadata.ctimeMs].join(':'))
|
|
356
|
+
.digest('hex');
|
|
357
|
+
}
|
|
358
|
+
/** Reads one regular absolute file using the Node account's filesystem permissions. */
|
|
359
|
+
export async function readAbsoluteTextFile(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
|
|
360
|
+
const { file, metadata } = await resolveAbsoluteFile(requestedPath);
|
|
361
|
+
if (metadata.size > FILE_READ_LIMIT * 16)
|
|
362
|
+
throw new Error('FILE_TOO_LARGE');
|
|
363
|
+
if (!Number.isSafeInteger(offset) ||
|
|
364
|
+
offset < 0 ||
|
|
365
|
+
!Number.isSafeInteger(limit) ||
|
|
366
|
+
limit < 1 ||
|
|
367
|
+
limit > FILE_READ_LIMIT)
|
|
368
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
369
|
+
const source = await readFile(file);
|
|
370
|
+
const binary = source.includes(0) || !isValidUtf8(source);
|
|
371
|
+
const end = Math.min(source.length, offset + limit);
|
|
372
|
+
if (binary) {
|
|
373
|
+
const slice = source.subarray(offset, end);
|
|
374
|
+
return {
|
|
375
|
+
path: file,
|
|
376
|
+
encoding: 'base64',
|
|
377
|
+
size: source.length,
|
|
378
|
+
content: slice.toString('base64'),
|
|
379
|
+
truncated: end < source.length,
|
|
380
|
+
nextOffset: end < source.length ? end : null
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
|
|
384
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
385
|
+
let textEnd = end;
|
|
386
|
+
if (textEnd < source.length && isUtf8ContinuationByte(source[textEnd])) {
|
|
387
|
+
while (textEnd > offset && isUtf8ContinuationByte(source[textEnd]))
|
|
388
|
+
textEnd -= 1;
|
|
389
|
+
if (textEnd === offset) {
|
|
390
|
+
textEnd += 1;
|
|
391
|
+
while (textEnd < source.length && isUtf8ContinuationByte(source[textEnd]))
|
|
392
|
+
textEnd += 1;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const nextOffset = textEnd < source.length ? textEnd : null;
|
|
396
|
+
return {
|
|
397
|
+
path: file,
|
|
398
|
+
encoding: 'utf-8',
|
|
399
|
+
size: source.length,
|
|
400
|
+
content: new TextDecoder('utf-8', { fatal: true }).decode(source.subarray(offset, textEnd)),
|
|
401
|
+
truncated: nextOffset !== null,
|
|
402
|
+
nextOffset
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
export function normalizeFilePreviewPath(requestedPath) {
|
|
406
|
+
if (!/^sandbox:/iu.test(requestedPath))
|
|
407
|
+
return requestedPath;
|
|
408
|
+
let decoded;
|
|
409
|
+
try {
|
|
410
|
+
decoded = decodeURIComponent(requestedPath.slice(requestedPath.indexOf(':') + 1));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
414
|
+
}
|
|
415
|
+
const normalized = decoded.replace(/^\/([a-z]:[\\/])/iu, '$1');
|
|
416
|
+
if (normalized.length === 0 ||
|
|
417
|
+
/^[\\/]{2,}[?.](?:[\\/]|$)/u.test(decoded) ||
|
|
418
|
+
(!isAbsolute(normalized) && !win32.isAbsolute(normalized)) ||
|
|
419
|
+
normalized.includes('\0') ||
|
|
420
|
+
normalized.includes('?') ||
|
|
421
|
+
normalized.includes('#'))
|
|
422
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
423
|
+
return normalized;
|
|
424
|
+
}
|
|
425
|
+
async function resolveAbsoluteFile(requestedPath) {
|
|
426
|
+
if (!isAbsolute(requestedPath))
|
|
427
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
428
|
+
const file = await realpath(requestedPath).catch((error) => {
|
|
429
|
+
if (error.code === 'ENOENT')
|
|
430
|
+
throw new Error('FILE_NOT_FOUND');
|
|
431
|
+
throw error;
|
|
432
|
+
});
|
|
433
|
+
const metadata = await lstat(file);
|
|
434
|
+
if (!metadata.isFile())
|
|
435
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
436
|
+
return { file, metadata };
|
|
437
|
+
}
|
|
438
|
+
export async function resolveWorkspaceFileManagerTarget(workspaceRoot, requestedPath) {
|
|
439
|
+
const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
|
|
440
|
+
const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
|
|
441
|
+
if (entry.metadata.isDirectory())
|
|
442
|
+
return { path: entry.path, kind: 'DIRECTORY' };
|
|
443
|
+
if (entry.metadata.isFile())
|
|
444
|
+
return { path: entry.path, kind: 'FILE' };
|
|
445
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
446
|
+
}
|
|
447
|
+
function encodeDirectoryCursor(path, name) {
|
|
448
|
+
return Buffer.from(JSON.stringify({ path, name })).toString('base64url');
|
|
449
|
+
}
|
|
450
|
+
function decodeDirectoryCursor(cursor, expectedPath) {
|
|
451
|
+
if (cursor === undefined)
|
|
452
|
+
return undefined;
|
|
453
|
+
try {
|
|
454
|
+
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
455
|
+
if (typeof value !== 'object' ||
|
|
456
|
+
value === null ||
|
|
457
|
+
value.path !== expectedPath ||
|
|
458
|
+
typeof value.name !== 'string')
|
|
459
|
+
throw new Error();
|
|
460
|
+
return value.name;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
throw new Error('DIRECTORY_CURSOR_INVALID');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** Resolving both candidate and roots rejects Linux symlinks which point outside a root. */
|