@easonwumac/computer-linker 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +230 -0
- package/LICENSE +21 -0
- package/README.md +539 -0
- package/SECURITY.md +48 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +360 -0
- package/dist/audit.d.ts +70 -0
- package/dist/audit.js +102 -0
- package/dist/capabilities.d.ts +98 -0
- package/dist/capabilities.js +718 -0
- package/dist/capability-policy.d.ts +22 -0
- package/dist/capability-policy.js +103 -0
- package/dist/chatgpt.d.ts +167 -0
- package/dist/chatgpt.js +561 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4621 -0
- package/dist/client-smoke.d.ts +44 -0
- package/dist/client-smoke.js +639 -0
- package/dist/client.d.ts +217 -0
- package/dist/client.js +357 -0
- package/dist/codex-runs.d.ts +35 -0
- package/dist/codex-runs.js +66 -0
- package/dist/computer-contract.d.ts +33 -0
- package/dist/computer-contract.js +384 -0
- package/dist/computer-operation-registry.d.ts +45 -0
- package/dist/computer-operation-registry.js +179 -0
- package/dist/config-diagnostics.d.ts +11 -0
- package/dist/config-diagnostics.js +185 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +69 -0
- package/dist/history-insights.d.ts +132 -0
- package/dist/history-insights.js +457 -0
- package/dist/http-auth.d.ts +3 -0
- package/dist/http-auth.js +15 -0
- package/dist/mcp-surface.d.ts +5 -0
- package/dist/mcp-surface.js +25 -0
- package/dist/oauth-provider.d.ts +52 -0
- package/dist/oauth-provider.js +325 -0
- package/dist/package-metadata.d.ts +7 -0
- package/dist/package-metadata.js +24 -0
- package/dist/permissions.d.ts +43 -0
- package/dist/permissions.js +150 -0
- package/dist/platform-shell.d.ts +28 -0
- package/dist/platform-shell.js +124 -0
- package/dist/processes.d.ts +50 -0
- package/dist/processes.js +178 -0
- package/dist/profile.d.ts +159 -0
- package/dist/profile.js +416 -0
- package/dist/screenshot.d.ts +47 -0
- package/dist/screenshot.js +302 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.js +340 -0
- package/dist/security.d.ts +10 -0
- package/dist/security.js +108 -0
- package/dist/sensitive-files.d.ts +4 -0
- package/dist/sensitive-files.js +96 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +713 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.js +486 -0
- package/dist/sessions.d.ts +26 -0
- package/dist/sessions.js +34 -0
- package/dist/tunnels.d.ts +161 -0
- package/dist/tunnels.js +1243 -0
- package/dist/workspace-operations.d.ts +170 -0
- package/dist/workspace-operations.js +3219 -0
- package/dist/workspaces.d.ts +61 -0
- package/dist/workspaces.js +353 -0
- package/docs/agent-instructions.md +65 -0
- package/docs/alpha-evidence.example.json +54 -0
- package/docs/api-compatibility.md +56 -0
- package/docs/architecture.md +561 -0
- package/docs/chatgpt-setup.md +397 -0
- package/docs/client-recipes.md +98 -0
- package/docs/client-sdk.md +163 -0
- package/docs/computer-operation-v1.schema.json +143 -0
- package/docs/manual-test-plan.md +322 -0
- package/docs/product-spec.md +911 -0
- package/docs/release-checklist.md +285 -0
- package/docs/service-mode.md +99 -0
- package/examples/minimal-mcp-client.mjs +114 -0
- package/package.json +87 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type LocalPortConfig, type ResolvedExposedPath } from "./permissions.js";
|
|
2
|
+
export interface Workspace {
|
|
3
|
+
id: string;
|
|
4
|
+
root: string;
|
|
5
|
+
exposedPath: ResolvedExposedPath;
|
|
6
|
+
}
|
|
7
|
+
export interface WorkspaceCandidate {
|
|
8
|
+
path: string;
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
permissions: ResolvedExposedPath["permissions"];
|
|
12
|
+
}
|
|
13
|
+
export interface WorkspacePathInfo {
|
|
14
|
+
path: string;
|
|
15
|
+
name: string;
|
|
16
|
+
type: "file" | "directory" | "symlink" | "other";
|
|
17
|
+
size: number;
|
|
18
|
+
modifiedAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface WorkspaceTreeOptions {
|
|
21
|
+
maxDepth?: number;
|
|
22
|
+
maxEntries?: number;
|
|
23
|
+
includeFiles?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface WorkspaceInstructionFile {
|
|
26
|
+
path: string;
|
|
27
|
+
name: string;
|
|
28
|
+
content: string;
|
|
29
|
+
size: number;
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface WorkspaceInstructionsOptions {
|
|
33
|
+
maxBytes?: number;
|
|
34
|
+
}
|
|
35
|
+
export declare class WorkspaceRegistry {
|
|
36
|
+
private readonly config;
|
|
37
|
+
private readonly workspaces;
|
|
38
|
+
constructor(config: LocalPortConfig);
|
|
39
|
+
listDefinedWorkspaces(): ResolvedExposedPath[];
|
|
40
|
+
listWorkspaceCandidates(): Promise<WorkspaceCandidate[]>;
|
|
41
|
+
openWorkspace(workspaceRef: string): Promise<Workspace>;
|
|
42
|
+
private findWorkspaceByRef;
|
|
43
|
+
getWorkspace(workspaceId: string): Workspace;
|
|
44
|
+
resolvePath(workspace: Workspace, inputPath: string): string;
|
|
45
|
+
resolveExistingPath(workspace: Workspace, inputPath: string): Promise<string>;
|
|
46
|
+
resolveWritablePath(workspace: Workspace, inputPath: string): Promise<string>;
|
|
47
|
+
readFile(workspaceId: string, path: string): Promise<string>;
|
|
48
|
+
writeFile(workspaceId: string, path: string, content: string): Promise<void>;
|
|
49
|
+
createFile(workspaceId: string, path: string, content: string): Promise<void>;
|
|
50
|
+
editFile(workspaceId: string, path: string, oldText: string, newText: string): Promise<number>;
|
|
51
|
+
listDirectory(workspaceId: string, path: string): Promise<string[]>;
|
|
52
|
+
listDirectoryEntries(workspaceId: string, path: string): Promise<WorkspacePathInfo[]>;
|
|
53
|
+
tree(workspaceId: string, path: string, options?: WorkspaceTreeOptions): Promise<WorkspacePathInfo[]>;
|
|
54
|
+
instructions(workspaceId: string, path: string, options?: WorkspaceInstructionsOptions): Promise<WorkspaceInstructionFile[]>;
|
|
55
|
+
statPath(workspaceId: string, path: string): Promise<WorkspacePathInfo>;
|
|
56
|
+
private resolveInstructionTarget;
|
|
57
|
+
createDirectory(workspaceId: string, path: string): Promise<void>;
|
|
58
|
+
deletePath(workspaceId: string, path: string, recursive?: boolean): Promise<void>;
|
|
59
|
+
movePath(workspaceId: string, fromPath: string, toPath: string): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export declare function formatWorkspacePath(path: string, workspace: Workspace): string;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { assertPermission, isPathInsideRoot, } from "./permissions.js";
|
|
5
|
+
import { assertNonSensitiveWorkspacePath } from "./sensitive-files.js";
|
|
6
|
+
export class WorkspaceRegistry {
|
|
7
|
+
config;
|
|
8
|
+
workspaces = new Map();
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
listDefinedWorkspaces() {
|
|
13
|
+
return this.config.workspaces.map((entry) => ({
|
|
14
|
+
...entry,
|
|
15
|
+
path: resolve(entry.path),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
async listWorkspaceCandidates() {
|
|
19
|
+
return this.listDefinedWorkspaces()
|
|
20
|
+
.filter((workspace) => workspace.permissions.read)
|
|
21
|
+
.map((workspace) => ({
|
|
22
|
+
id: workspace.id,
|
|
23
|
+
name: workspace.name,
|
|
24
|
+
path: workspace.path,
|
|
25
|
+
permissions: workspace.permissions,
|
|
26
|
+
}))
|
|
27
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
28
|
+
}
|
|
29
|
+
async openWorkspace(workspaceRef) {
|
|
30
|
+
const exposedPath = this.findWorkspaceByRef(workspaceRef);
|
|
31
|
+
assertPermission(exposedPath, "read");
|
|
32
|
+
await mkdir(exposedPath.path, { recursive: true });
|
|
33
|
+
if (!(await isDirectory(exposedPath.path))) {
|
|
34
|
+
throw new Error(`Workspace root must be a directory: ${exposedPath.path}`);
|
|
35
|
+
}
|
|
36
|
+
const realRoot = await realpath(exposedPath.path);
|
|
37
|
+
const workspace = {
|
|
38
|
+
id: `ws_${randomUUID()}`,
|
|
39
|
+
root: realRoot,
|
|
40
|
+
exposedPath: {
|
|
41
|
+
...exposedPath,
|
|
42
|
+
path: realRoot,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
this.workspaces.set(workspace.id, workspace);
|
|
46
|
+
return workspace;
|
|
47
|
+
}
|
|
48
|
+
findWorkspaceByRef(workspaceRef) {
|
|
49
|
+
const resolvedRef = resolve(workspaceRef);
|
|
50
|
+
const workspace = this.listDefinedWorkspaces().find((entry) => entry.id === workspaceRef ||
|
|
51
|
+
entry.name === workspaceRef ||
|
|
52
|
+
entry.path === resolvedRef);
|
|
53
|
+
if (!workspace) {
|
|
54
|
+
throw new Error(`Unknown configured workspace: ${workspaceRef}`);
|
|
55
|
+
}
|
|
56
|
+
return workspace;
|
|
57
|
+
}
|
|
58
|
+
getWorkspace(workspaceId) {
|
|
59
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
60
|
+
if (!workspace)
|
|
61
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}`);
|
|
62
|
+
return workspace;
|
|
63
|
+
}
|
|
64
|
+
resolvePath(workspace, inputPath) {
|
|
65
|
+
const absolutePath = resolve(workspace.root, inputPath);
|
|
66
|
+
if (!isPathInsideRoot(absolutePath, workspace.root)) {
|
|
67
|
+
throw new Error(`Path is outside workspace root: ${inputPath}`);
|
|
68
|
+
}
|
|
69
|
+
if (!isPathInsideRoot(absolutePath, workspace.exposedPath.path)) {
|
|
70
|
+
throw new Error(`Path is outside exposed path: ${inputPath}`);
|
|
71
|
+
}
|
|
72
|
+
return absolutePath;
|
|
73
|
+
}
|
|
74
|
+
async resolveExistingPath(workspace, inputPath) {
|
|
75
|
+
const absolutePath = this.resolvePath(workspace, inputPath);
|
|
76
|
+
await assertRealPathInside(workspace, absolutePath, inputPath);
|
|
77
|
+
return absolutePath;
|
|
78
|
+
}
|
|
79
|
+
async resolveWritablePath(workspace, inputPath) {
|
|
80
|
+
const absolutePath = this.resolvePath(workspace, inputPath);
|
|
81
|
+
try {
|
|
82
|
+
await assertRealPathInside(workspace, absolutePath, inputPath);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!isNotFoundError(error))
|
|
86
|
+
throw error;
|
|
87
|
+
if (await pathExists(absolutePath))
|
|
88
|
+
throw error;
|
|
89
|
+
await assertRealPathInside(workspace, dirname(absolutePath), dirname(inputPath));
|
|
90
|
+
}
|
|
91
|
+
return absolutePath;
|
|
92
|
+
}
|
|
93
|
+
async readFile(workspaceId, path) {
|
|
94
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
95
|
+
assertPermission(workspace.exposedPath, "read");
|
|
96
|
+
const absolutePath = await this.resolveExistingPath(workspace, path);
|
|
97
|
+
assertNonSensitiveWorkspacePath(formatWorkspacePath(absolutePath, workspace), "read");
|
|
98
|
+
return readFile(absolutePath, "utf8");
|
|
99
|
+
}
|
|
100
|
+
async writeFile(workspaceId, path, content) {
|
|
101
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
102
|
+
assertPermission(workspace.exposedPath, "write");
|
|
103
|
+
const absolutePath = await this.resolveWritablePath(workspace, path);
|
|
104
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
105
|
+
await writeFile(absolutePath, content, "utf8");
|
|
106
|
+
}
|
|
107
|
+
async createFile(workspaceId, path, content) {
|
|
108
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
109
|
+
assertPermission(workspace.exposedPath, "write");
|
|
110
|
+
const absolutePath = await this.resolveWritablePath(workspace, path);
|
|
111
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
112
|
+
try {
|
|
113
|
+
await writeFile(absolutePath, content, { encoding: "utf8", flag: "wx" });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
117
|
+
throw new Error(`File already exists: ${path}`);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async editFile(workspaceId, path, oldText, newText) {
|
|
123
|
+
const current = await this.readFile(workspaceId, path);
|
|
124
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
125
|
+
assertPermission(workspace.exposedPath, "write");
|
|
126
|
+
const matches = current.split(oldText).length - 1;
|
|
127
|
+
if (matches !== 1) {
|
|
128
|
+
throw new Error(`edit_file expected exactly one match, found ${matches}`);
|
|
129
|
+
}
|
|
130
|
+
await this.writeFile(workspaceId, path, current.replace(oldText, newText));
|
|
131
|
+
return matches;
|
|
132
|
+
}
|
|
133
|
+
async listDirectory(workspaceId, path) {
|
|
134
|
+
return (await this.listDirectoryEntries(workspaceId, path))
|
|
135
|
+
.map((entry) => `${entry.name}${entry.type === "directory" ? "/" : ""}`);
|
|
136
|
+
}
|
|
137
|
+
async listDirectoryEntries(workspaceId, path) {
|
|
138
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
139
|
+
assertPermission(workspace.exposedPath, "read");
|
|
140
|
+
const directory = await this.resolveExistingPath(workspace, path);
|
|
141
|
+
const entries = await opendir(directory);
|
|
142
|
+
const results = [];
|
|
143
|
+
for await (const entry of entries) {
|
|
144
|
+
results.push(await pathInfo(join(directory, entry.name), workspace));
|
|
145
|
+
}
|
|
146
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
147
|
+
}
|
|
148
|
+
async tree(workspaceId, path, options = {}) {
|
|
149
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
150
|
+
assertPermission(workspace.exposedPath, "read");
|
|
151
|
+
const root = await this.resolveExistingPath(workspace, path);
|
|
152
|
+
const maxDepth = normalizePositiveInteger(options.maxDepth, 2, 1000);
|
|
153
|
+
const maxEntries = normalizePositiveInteger(options.maxEntries, 200, 1000);
|
|
154
|
+
const includeFiles = options.includeFiles ?? true;
|
|
155
|
+
const results = [];
|
|
156
|
+
const walk = async (directory, depth) => {
|
|
157
|
+
if (results.length >= maxEntries)
|
|
158
|
+
return;
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = await opendir(directory);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const paths = [];
|
|
167
|
+
for await (const entry of entries) {
|
|
168
|
+
if (entry.isDirectory() && SKIPPED_TREE_DIRECTORIES.has(entry.name))
|
|
169
|
+
continue;
|
|
170
|
+
paths.push(join(directory, entry.name));
|
|
171
|
+
}
|
|
172
|
+
paths.sort((a, b) => basename(a).localeCompare(basename(b)));
|
|
173
|
+
for (const entryPath of paths) {
|
|
174
|
+
if (results.length >= maxEntries)
|
|
175
|
+
return;
|
|
176
|
+
const info = await pathInfo(entryPath, workspace);
|
|
177
|
+
if (includeFiles || info.type === "directory")
|
|
178
|
+
results.push(info);
|
|
179
|
+
if (info.type === "directory" && depth < maxDepth) {
|
|
180
|
+
await walk(entryPath, depth + 1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
await walk(root, 1);
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
async instructions(workspaceId, path, options = {}) {
|
|
188
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
189
|
+
assertPermission(workspace.exposedPath, "read");
|
|
190
|
+
const target = await this.resolveInstructionTarget(workspace, path);
|
|
191
|
+
const directory = await instructionSearchDirectory(target);
|
|
192
|
+
const maxBytes = normalizePositiveInteger(options.maxBytes, 64 * 1024, 256 * 1024);
|
|
193
|
+
const files = [];
|
|
194
|
+
for (const directoryPath of ancestorDirectories(workspace.root, directory)) {
|
|
195
|
+
for (const name of INSTRUCTION_FILE_NAMES) {
|
|
196
|
+
const instructionPath = join(directoryPath, name);
|
|
197
|
+
try {
|
|
198
|
+
const info = await lstat(instructionPath);
|
|
199
|
+
if (!info.isFile())
|
|
200
|
+
continue;
|
|
201
|
+
const content = await readFile(instructionPath, "utf8");
|
|
202
|
+
files.push({
|
|
203
|
+
path: formatWorkspacePath(instructionPath, workspace),
|
|
204
|
+
name,
|
|
205
|
+
content: content.slice(0, maxBytes),
|
|
206
|
+
size: info.size,
|
|
207
|
+
truncated: info.size > maxBytes,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Missing instruction files are expected in most directories.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
async statPath(workspaceId, path) {
|
|
218
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
219
|
+
assertPermission(workspace.exposedPath, "read");
|
|
220
|
+
const absolutePath = this.resolvePath(workspace, path);
|
|
221
|
+
await assertRealPathInside(workspace, absolutePath, path);
|
|
222
|
+
return pathInfo(absolutePath, workspace);
|
|
223
|
+
}
|
|
224
|
+
async resolveInstructionTarget(workspace, inputPath) {
|
|
225
|
+
const absolutePath = this.resolvePath(workspace, inputPath);
|
|
226
|
+
try {
|
|
227
|
+
await assertRealPathInside(workspace, absolutePath, inputPath);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!isNotFoundError(error))
|
|
231
|
+
throw error;
|
|
232
|
+
if (await pathExists(absolutePath))
|
|
233
|
+
throw error;
|
|
234
|
+
await assertRealPathInside(workspace, await nearestExistingParent(absolutePath), inputPath);
|
|
235
|
+
}
|
|
236
|
+
return absolutePath;
|
|
237
|
+
}
|
|
238
|
+
async createDirectory(workspaceId, path) {
|
|
239
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
240
|
+
assertPermission(workspace.exposedPath, "write");
|
|
241
|
+
const absolutePath = this.resolvePath(workspace, path);
|
|
242
|
+
await assertRealPathInside(workspace, await nearestExistingParent(absolutePath), path);
|
|
243
|
+
await mkdir(absolutePath, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
async deletePath(workspaceId, path, recursive = false) {
|
|
246
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
247
|
+
assertPermission(workspace.exposedPath, "write");
|
|
248
|
+
const absolutePath = this.resolvePath(workspace, path);
|
|
249
|
+
await assertRealPathInside(workspace, absolutePath, path);
|
|
250
|
+
assertNotWorkspaceRoot(workspace, absolutePath, "delete");
|
|
251
|
+
await rm(absolutePath, { recursive, force: false });
|
|
252
|
+
}
|
|
253
|
+
async movePath(workspaceId, fromPath, toPath) {
|
|
254
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
255
|
+
assertPermission(workspace.exposedPath, "write");
|
|
256
|
+
const absoluteFromPath = this.resolvePath(workspace, fromPath);
|
|
257
|
+
const absoluteToPath = await this.resolveWritablePath(workspace, toPath);
|
|
258
|
+
await assertRealPathInside(workspace, absoluteFromPath, fromPath);
|
|
259
|
+
assertNotWorkspaceRoot(workspace, absoluteFromPath, "move");
|
|
260
|
+
await mkdir(dirname(absoluteToPath), { recursive: true });
|
|
261
|
+
await rename(absoluteFromPath, absoluteToPath);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function isDirectory(path) {
|
|
265
|
+
try {
|
|
266
|
+
return (await stat(path)).isDirectory();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export function formatWorkspacePath(path, workspace) {
|
|
273
|
+
const relationship = relative(workspace.root, path);
|
|
274
|
+
return relationship ? relationship.split(sep).join("/") : ".";
|
|
275
|
+
}
|
|
276
|
+
async function pathInfo(path, workspace) {
|
|
277
|
+
const info = await lstat(path);
|
|
278
|
+
return {
|
|
279
|
+
path: formatWorkspacePath(path, workspace),
|
|
280
|
+
name: basename(path) || ".",
|
|
281
|
+
type: info.isDirectory() ? "directory" : info.isFile() ? "file" : info.isSymbolicLink() ? "symlink" : "other",
|
|
282
|
+
size: info.size,
|
|
283
|
+
modifiedAt: info.mtime.toISOString(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function assertNotWorkspaceRoot(workspace, path, action) {
|
|
287
|
+
if (resolve(path) !== resolve(workspace.root))
|
|
288
|
+
return;
|
|
289
|
+
throw new Error(`Refusing to ${action} the workspace root`);
|
|
290
|
+
}
|
|
291
|
+
async function assertRealPathInside(workspace, path, inputPath) {
|
|
292
|
+
const realPath = await realpath(path);
|
|
293
|
+
if (!isPathInsideRoot(realPath, workspace.root) || !isPathInsideRoot(realPath, workspace.exposedPath.path)) {
|
|
294
|
+
throw new Error(`Path resolves outside workspace: ${inputPath}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function nearestExistingParent(path) {
|
|
298
|
+
let current = path;
|
|
299
|
+
while (!(await pathExists(current))) {
|
|
300
|
+
const parent = dirname(current);
|
|
301
|
+
if (parent === current)
|
|
302
|
+
return current;
|
|
303
|
+
current = parent;
|
|
304
|
+
}
|
|
305
|
+
return current;
|
|
306
|
+
}
|
|
307
|
+
async function pathExists(path) {
|
|
308
|
+
try {
|
|
309
|
+
await lstat(path);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (isNotFoundError(error))
|
|
314
|
+
return false;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function isNotFoundError(error) {
|
|
319
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
320
|
+
}
|
|
321
|
+
function normalizePositiveInteger(value, fallback, max) {
|
|
322
|
+
return Number.isInteger(value) && value !== undefined && value > 0 ? Math.min(value, max) : fallback;
|
|
323
|
+
}
|
|
324
|
+
async function instructionSearchDirectory(path) {
|
|
325
|
+
try {
|
|
326
|
+
const info = await lstat(path);
|
|
327
|
+
return info.isDirectory() ? path : dirname(path);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return dirname(path);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function ancestorDirectories(root, directory) {
|
|
334
|
+
const resolvedRoot = resolve(root);
|
|
335
|
+
let current = resolve(directory);
|
|
336
|
+
const directories = [];
|
|
337
|
+
while (isPathInsideRoot(current, resolvedRoot)) {
|
|
338
|
+
directories.push(current);
|
|
339
|
+
if (current === resolvedRoot)
|
|
340
|
+
break;
|
|
341
|
+
current = dirname(current);
|
|
342
|
+
}
|
|
343
|
+
return directories.reverse();
|
|
344
|
+
}
|
|
345
|
+
const SKIPPED_TREE_DIRECTORIES = new Set([
|
|
346
|
+
".git",
|
|
347
|
+
"node_modules",
|
|
348
|
+
"dist",
|
|
349
|
+
"build",
|
|
350
|
+
".next",
|
|
351
|
+
".cache",
|
|
352
|
+
]);
|
|
353
|
+
const INSTRUCTION_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Agent Instructions
|
|
2
|
+
|
|
3
|
+
Use these instructions when connecting an MCP-capable agent to Workspace
|
|
4
|
+
Linker.
|
|
5
|
+
|
|
6
|
+
## First Call
|
|
7
|
+
|
|
8
|
+
Call `get_computer_info` before any workspace action. Read:
|
|
9
|
+
|
|
10
|
+
- available scopes
|
|
11
|
+
- workspace paths and names
|
|
12
|
+
- permissions
|
|
13
|
+
- `computerOperationRegistry`
|
|
14
|
+
- local/public MCP URL status
|
|
15
|
+
- safety boundaries
|
|
16
|
+
|
|
17
|
+
## Normal Flow
|
|
18
|
+
|
|
19
|
+
1. Choose one scope from `get_computer_info`.
|
|
20
|
+
2. Call `computer_operation` with the generic envelope:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"scope": "workspace-id",
|
|
25
|
+
"op": "file.list",
|
|
26
|
+
"target": ".",
|
|
27
|
+
"input": {},
|
|
28
|
+
"options": {}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Use dotted operation names from `computerOperationRegistry`.
|
|
33
|
+
4. Use write, shell, command, Codex, or screen operations only when the selected
|
|
34
|
+
scope reports the required permission.
|
|
35
|
+
5. Call `get_operation_history` when debugging recent actions or connection
|
|
36
|
+
behavior.
|
|
37
|
+
|
|
38
|
+
## Preferred Operations
|
|
39
|
+
|
|
40
|
+
- Inspect files: `file.list`, `file.read`, `file.search`
|
|
41
|
+
- Edit files: `file.write`, `file.edit`, `file.patch`
|
|
42
|
+
- Git inspection: `git.status`, `git.diff`
|
|
43
|
+
- Git mutation: `git.stage`, `git.commit`
|
|
44
|
+
- Package commands: `package.run`
|
|
45
|
+
- Shell command: `command.run`
|
|
46
|
+
|
|
47
|
+
The exact list can grow. Always prefer names returned by
|
|
48
|
+
`computerOperationRegistry`.
|
|
49
|
+
|
|
50
|
+
## Sensitive Files
|
|
51
|
+
|
|
52
|
+
Do not request secrets unless the user explicitly asks outside Workspace
|
|
53
|
+
Linker. Direct reads and text searches block common sensitive files by default,
|
|
54
|
+
including `.env*`, private keys, credential JSON files, and cloud CLI
|
|
55
|
+
credential folders. Treat missing matches in those files as an intentional
|
|
56
|
+
safety boundary.
|
|
57
|
+
|
|
58
|
+
## Avoid By Default
|
|
59
|
+
|
|
60
|
+
Do not call compatibility tools such as `list_workspaces`, `open_workspace`, or
|
|
61
|
+
`workspace_operation` unless the MCP client cannot send the generic
|
|
62
|
+
`computer_operation` envelope.
|
|
63
|
+
|
|
64
|
+
Do not assume shell or write access. Computer Linker may expose read-only,
|
|
65
|
+
coding, or full-trust scopes.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "computer-linker-alpha-evidence",
|
|
3
|
+
"schemaVersion": 1,
|
|
4
|
+
"testedAt": "2026-06-24T00:00:00.000Z",
|
|
5
|
+
"package": {
|
|
6
|
+
"name": "computer-linker",
|
|
7
|
+
"version": "0.1.0"
|
|
8
|
+
},
|
|
9
|
+
"git": {
|
|
10
|
+
"head": "replace-with-tested-git-head",
|
|
11
|
+
"shortHead": "replace-head"
|
|
12
|
+
},
|
|
13
|
+
"environment": {
|
|
14
|
+
"platform": "win32",
|
|
15
|
+
"arch": "x64",
|
|
16
|
+
"node": "v22.x"
|
|
17
|
+
},
|
|
18
|
+
"target": {
|
|
19
|
+
"client": "External MCP client",
|
|
20
|
+
"exposure": "openai",
|
|
21
|
+
"tunnelOrUrl": "redacted-tunnel-id-or-public-origin",
|
|
22
|
+
"mcpPath": "/mcp",
|
|
23
|
+
"scope": "app"
|
|
24
|
+
},
|
|
25
|
+
"checks": [
|
|
26
|
+
{
|
|
27
|
+
"id": "external-mcp-tool-flow",
|
|
28
|
+
"status": "pass",
|
|
29
|
+
"evidence": "External MCP client called get_computer_info, computer_operation, and get_operation_history successfully."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "tunnel-transport",
|
|
33
|
+
"status": "pass",
|
|
34
|
+
"evidence": "Tunnel exposure connected from outside 127.0.0.1 and reached the local MCP server."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "mcp-only-public-surface",
|
|
38
|
+
"status": "pass",
|
|
39
|
+
"evidence": "Public exposure allowed /mcp and did not expose /api/v1 or /healthz."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "operation-history-reviewed",
|
|
43
|
+
"status": "pass",
|
|
44
|
+
"evidence": "history --view connections and history --view last showed the expected external MCP session and no secret payloads."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "client-instructions-usable",
|
|
48
|
+
"status": "pass",
|
|
49
|
+
"evidence": "README Agent Instructions produced the expected first operations in the external client."
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"redactionConfirmed": true,
|
|
53
|
+
"notes": "Do not store owner tokens, API keys, bearer headers, screenshots, or private file contents in this evidence file."
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# API Compatibility
|
|
2
|
+
|
|
3
|
+
Computer Linker is still `0.x`, but the public MCP surface is intentionally
|
|
4
|
+
small and treated as the product contract.
|
|
5
|
+
|
|
6
|
+
## Stable MCP Surface
|
|
7
|
+
|
|
8
|
+
Default MCP clients should use only these tools:
|
|
9
|
+
|
|
10
|
+
- `get_computer_info`
|
|
11
|
+
- `computer_operation`
|
|
12
|
+
- `get_operation_history`
|
|
13
|
+
|
|
14
|
+
The stable `computer_operation` request envelope is:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"scope": "workspace-id",
|
|
19
|
+
"op": "file.list",
|
|
20
|
+
"target": ".",
|
|
21
|
+
"input": {},
|
|
22
|
+
"options": {}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The stable operation result envelope includes:
|
|
27
|
+
|
|
28
|
+
- `ok`
|
|
29
|
+
- `operationId`
|
|
30
|
+
- `scope`
|
|
31
|
+
- `op`
|
|
32
|
+
- `startedAt`
|
|
33
|
+
- `durationMs`
|
|
34
|
+
- `data`
|
|
35
|
+
- `error`
|
|
36
|
+
- `warnings`
|
|
37
|
+
|
|
38
|
+
New dotted operations, new optional fields, and additional diagnostic metadata
|
|
39
|
+
are non-breaking additions.
|
|
40
|
+
|
|
41
|
+
## Compatibility Tools
|
|
42
|
+
|
|
43
|
+
Tools such as `list_workspaces`, `open_workspace`, and `workspace_operation`
|
|
44
|
+
exist for older clients and migration. New clients should not depend on them
|
|
45
|
+
unless a specific MCP client cannot use the generic `computer_operation`
|
|
46
|
+
contract.
|
|
47
|
+
|
|
48
|
+
Removing or renaming a default MCP tool, removing required envelope fields, or
|
|
49
|
+
changing operation semantics is a breaking change and must be called out in the
|
|
50
|
+
changelog.
|
|
51
|
+
|
|
52
|
+
## JSON API
|
|
53
|
+
|
|
54
|
+
The JSON API under `/api/v1` is for local and trusted-private diagnostics,
|
|
55
|
+
SDK usage, and health checks. Public cloud exposure should route only `/mcp`
|
|
56
|
+
unless an operator intentionally exposes more.
|