@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,325 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidTokenError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
5
|
+
import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
|
|
6
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
export class OAuthStateStore {
|
|
8
|
+
statePath;
|
|
9
|
+
clients = new Map();
|
|
10
|
+
accessTokens = new Map();
|
|
11
|
+
refreshTokens = new Map();
|
|
12
|
+
constructor(statePath) {
|
|
13
|
+
this.statePath = statePath;
|
|
14
|
+
const state = statePath ? readOAuthState(statePath) : emptyOAuthState();
|
|
15
|
+
for (const client of state.clients)
|
|
16
|
+
this.clients.set(client.client_id, client);
|
|
17
|
+
for (const [token, record] of Object.entries(state.accessTokens))
|
|
18
|
+
this.accessTokens.set(token, record);
|
|
19
|
+
for (const [token, record] of Object.entries(state.refreshTokens))
|
|
20
|
+
this.refreshTokens.set(token, record);
|
|
21
|
+
this.pruneExpiredTokens();
|
|
22
|
+
}
|
|
23
|
+
getClient(clientId) {
|
|
24
|
+
return this.clients.get(clientId);
|
|
25
|
+
}
|
|
26
|
+
registerClient(client) {
|
|
27
|
+
const registered = {
|
|
28
|
+
...client,
|
|
29
|
+
client_id: `lp_client_${randomUUID()}`,
|
|
30
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
31
|
+
};
|
|
32
|
+
this.clients.set(registered.client_id, registered);
|
|
33
|
+
this.save();
|
|
34
|
+
return registered;
|
|
35
|
+
}
|
|
36
|
+
getAccessToken(token) {
|
|
37
|
+
this.pruneExpiredTokens();
|
|
38
|
+
return this.accessTokens.get(token);
|
|
39
|
+
}
|
|
40
|
+
setAccessToken(token, record) {
|
|
41
|
+
this.accessTokens.set(token, record);
|
|
42
|
+
this.save();
|
|
43
|
+
}
|
|
44
|
+
deleteAccessToken(token) {
|
|
45
|
+
if (this.accessTokens.delete(token))
|
|
46
|
+
this.save();
|
|
47
|
+
}
|
|
48
|
+
getRefreshToken(token) {
|
|
49
|
+
this.pruneExpiredTokens();
|
|
50
|
+
return this.refreshTokens.get(token);
|
|
51
|
+
}
|
|
52
|
+
setRefreshToken(token, record) {
|
|
53
|
+
this.refreshTokens.set(token, record);
|
|
54
|
+
this.save();
|
|
55
|
+
}
|
|
56
|
+
deleteRefreshToken(token) {
|
|
57
|
+
if (this.refreshTokens.delete(token))
|
|
58
|
+
this.save();
|
|
59
|
+
}
|
|
60
|
+
pruneExpiredTokens() {
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
let changed = false;
|
|
63
|
+
for (const [token, record] of this.accessTokens) {
|
|
64
|
+
if (record.expiresAt < now) {
|
|
65
|
+
this.accessTokens.delete(token);
|
|
66
|
+
changed = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [token, record] of this.refreshTokens) {
|
|
70
|
+
if (record.expiresAt < now) {
|
|
71
|
+
this.refreshTokens.delete(token);
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (changed)
|
|
76
|
+
this.save();
|
|
77
|
+
}
|
|
78
|
+
save() {
|
|
79
|
+
if (!this.statePath)
|
|
80
|
+
return;
|
|
81
|
+
mkdirSync(dirname(this.statePath), { recursive: true });
|
|
82
|
+
writeFileSync(this.statePath, JSON.stringify({
|
|
83
|
+
version: 1,
|
|
84
|
+
clients: Array.from(this.clients.values()),
|
|
85
|
+
accessTokens: Object.fromEntries(this.accessTokens),
|
|
86
|
+
refreshTokens: Object.fromEntries(this.refreshTokens),
|
|
87
|
+
}, null, 2) + "\n", { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class LocalPortOAuthProvider {
|
|
91
|
+
config;
|
|
92
|
+
clientsStore;
|
|
93
|
+
codes = new Map();
|
|
94
|
+
resourceServerUrl;
|
|
95
|
+
constructor(config, mcpServerUrl, options = {}) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
this.clientsStore = new OAuthStateStore(options.statePath);
|
|
98
|
+
this.resourceServerUrl = resourceUrlFromServerUrl(mcpServerUrl);
|
|
99
|
+
}
|
|
100
|
+
async authorize(client, params, res) {
|
|
101
|
+
if (params.resource && !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) {
|
|
102
|
+
throw new InvalidRequestError("Invalid OAuth resource");
|
|
103
|
+
}
|
|
104
|
+
if (!requestedScopesAllowed(params.scopes ?? this.config.scopes, this.config.scopes)) {
|
|
105
|
+
throw new InvalidRequestError("Requested scope is not supported");
|
|
106
|
+
}
|
|
107
|
+
if (!client.redirect_uris.includes(params.redirectUri)) {
|
|
108
|
+
throw new InvalidRequestError("Unregistered redirect_uri");
|
|
109
|
+
}
|
|
110
|
+
if (res.req.method !== "POST") {
|
|
111
|
+
res.status(200).setHeader("Content-Type", "text/html; charset=utf-8");
|
|
112
|
+
res.send(authorizeHtml({ client, params }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const providedToken = String(res.req.body?.owner_token ?? "");
|
|
116
|
+
if (!safeEquals(providedToken, this.config.ownerToken)) {
|
|
117
|
+
res.status(401).setHeader("Content-Type", "text/html; charset=utf-8");
|
|
118
|
+
res.send(authorizeHtml({ client, params, error: "Owner token was not accepted." }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const code = `lp_code_${randomUUID()}`;
|
|
122
|
+
this.codes.set(code, {
|
|
123
|
+
clientId: client.client_id,
|
|
124
|
+
params,
|
|
125
|
+
expiresAtMs: Date.now() + CODE_TTL_MS,
|
|
126
|
+
});
|
|
127
|
+
const redirectUrl = new URL(params.redirectUri);
|
|
128
|
+
redirectUrl.searchParams.set("code", code);
|
|
129
|
+
if (params.state !== undefined)
|
|
130
|
+
redirectUrl.searchParams.set("state", params.state);
|
|
131
|
+
res.redirect(302, redirectUrl.href);
|
|
132
|
+
}
|
|
133
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
134
|
+
return this.validCodeRecord(client, authorizationCode).params.codeChallenge;
|
|
135
|
+
}
|
|
136
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
137
|
+
const record = this.validCodeRecord(client, authorizationCode);
|
|
138
|
+
if (redirectUri && redirectUri !== record.params.redirectUri) {
|
|
139
|
+
throw new InvalidGrantError("redirect_uri does not match authorization request");
|
|
140
|
+
}
|
|
141
|
+
if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) {
|
|
142
|
+
throw new InvalidGrantError("Invalid resource");
|
|
143
|
+
}
|
|
144
|
+
this.codes.delete(authorizationCode);
|
|
145
|
+
return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, resource ?? record.params.resource);
|
|
146
|
+
}
|
|
147
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
148
|
+
const record = this.clientsStore.getRefreshToken(refreshToken);
|
|
149
|
+
if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
150
|
+
throw new InvalidGrantError("Invalid refresh token");
|
|
151
|
+
}
|
|
152
|
+
if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) {
|
|
153
|
+
throw new InvalidGrantError("Invalid resource");
|
|
154
|
+
}
|
|
155
|
+
const requestedScopes = scopes ?? record.scopes;
|
|
156
|
+
if (!requestedScopes.every((scope) => record.scopes.includes(scope))) {
|
|
157
|
+
throw new AccessDeniedError("Refresh token cannot grant requested scopes");
|
|
158
|
+
}
|
|
159
|
+
this.clientsStore.deleteRefreshToken(refreshToken);
|
|
160
|
+
return this.issueTokens(client.client_id, requestedScopes, resource ?? toUrl(record.resource));
|
|
161
|
+
}
|
|
162
|
+
async verifyAccessToken(token) {
|
|
163
|
+
const record = this.clientsStore.getAccessToken(token);
|
|
164
|
+
if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
165
|
+
throw new InvalidTokenError("Invalid or expired access token");
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
token,
|
|
169
|
+
clientId: record.clientId,
|
|
170
|
+
scopes: record.scopes,
|
|
171
|
+
expiresAt: record.expiresAt,
|
|
172
|
+
resource: toUrl(record.resource),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async revokeToken(_client, request) {
|
|
176
|
+
this.clientsStore.deleteAccessToken(request.token);
|
|
177
|
+
this.clientsStore.deleteRefreshToken(request.token);
|
|
178
|
+
}
|
|
179
|
+
validCodeRecord(client, authorizationCode) {
|
|
180
|
+
const record = this.codes.get(authorizationCode);
|
|
181
|
+
if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) {
|
|
182
|
+
throw new InvalidGrantError("Invalid authorization code");
|
|
183
|
+
}
|
|
184
|
+
return record;
|
|
185
|
+
}
|
|
186
|
+
issueTokens(clientId, scopes, resource) {
|
|
187
|
+
const now = Math.floor(Date.now() / 1000);
|
|
188
|
+
const accessToken = randomToken();
|
|
189
|
+
const refreshToken = randomToken();
|
|
190
|
+
const accessExpiresAt = now + this.config.accessTokenTtlSeconds;
|
|
191
|
+
const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds;
|
|
192
|
+
const resourceHref = resource?.href;
|
|
193
|
+
this.clientsStore.setAccessToken(accessToken, {
|
|
194
|
+
clientId,
|
|
195
|
+
scopes,
|
|
196
|
+
expiresAt: accessExpiresAt,
|
|
197
|
+
resource: resourceHref,
|
|
198
|
+
});
|
|
199
|
+
this.clientsStore.setRefreshToken(refreshToken, {
|
|
200
|
+
clientId,
|
|
201
|
+
scopes,
|
|
202
|
+
expiresAt: refreshExpiresAt,
|
|
203
|
+
resource: resourceHref,
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
access_token: accessToken,
|
|
207
|
+
token_type: "bearer",
|
|
208
|
+
expires_in: this.config.accessTokenTtlSeconds,
|
|
209
|
+
refresh_token: refreshToken,
|
|
210
|
+
scope: scopes.join(" "),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function readOAuthState(path) {
|
|
215
|
+
if (!existsSync(path))
|
|
216
|
+
return emptyOAuthState();
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
219
|
+
return {
|
|
220
|
+
version: 1,
|
|
221
|
+
clients: Array.isArray(parsed.clients) ? parsed.clients : [],
|
|
222
|
+
accessTokens: parsed.accessTokens && typeof parsed.accessTokens === "object" ? parsed.accessTokens : {},
|
|
223
|
+
refreshTokens: parsed.refreshTokens && typeof parsed.refreshTokens === "object" ? parsed.refreshTokens : {},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
228
|
+
throw new Error(`Unable to read OAuth state ${path}: ${reason}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function emptyOAuthState() {
|
|
232
|
+
return {
|
|
233
|
+
version: 1,
|
|
234
|
+
clients: [],
|
|
235
|
+
accessTokens: {},
|
|
236
|
+
refreshTokens: {},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function requestedScopesAllowed(requested, supported) {
|
|
240
|
+
return requested.every((scope) => supported.includes(scope));
|
|
241
|
+
}
|
|
242
|
+
function randomToken() {
|
|
243
|
+
return randomBytes(32).toString("base64url");
|
|
244
|
+
}
|
|
245
|
+
function safeEquals(a, b) {
|
|
246
|
+
const left = Buffer.from(a);
|
|
247
|
+
const right = Buffer.from(b);
|
|
248
|
+
if (left.byteLength !== right.byteLength)
|
|
249
|
+
return false;
|
|
250
|
+
return timingSafeEqual(left, right);
|
|
251
|
+
}
|
|
252
|
+
function toUrl(value) {
|
|
253
|
+
return value ? new URL(value) : undefined;
|
|
254
|
+
}
|
|
255
|
+
function htmlEscape(value) {
|
|
256
|
+
return value
|
|
257
|
+
.replaceAll("&", "&")
|
|
258
|
+
.replaceAll("<", "<")
|
|
259
|
+
.replaceAll(">", ">")
|
|
260
|
+
.replaceAll('"', """)
|
|
261
|
+
.replaceAll("'", "'");
|
|
262
|
+
}
|
|
263
|
+
function authorizeHtml(params) {
|
|
264
|
+
const clientName = params.client.client_name ?? params.client.client_id;
|
|
265
|
+
const scopes = params.params.scopes?.join(" ") || "computer-linker";
|
|
266
|
+
const resource = params.params.resource?.href ?? "Computer Linker MCP endpoint";
|
|
267
|
+
const error = params.error ? `<p class="error">${htmlEscape(params.error)}</p>` : "";
|
|
268
|
+
const fields = authorizationFormFields(params.client, params.params);
|
|
269
|
+
const hiddenFields = Object.entries(fields)
|
|
270
|
+
.filter((entry) => entry[1] !== undefined)
|
|
271
|
+
.map(([name, value]) => `<input type="hidden" name="${htmlEscape(name)}" value="${htmlEscape(value)}" />`)
|
|
272
|
+
.join("\n");
|
|
273
|
+
return `<!doctype html>
|
|
274
|
+
<html lang="en">
|
|
275
|
+
<head>
|
|
276
|
+
<meta charset="utf-8" />
|
|
277
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
278
|
+
<title>Connect Computer Linker</title>
|
|
279
|
+
<style>
|
|
280
|
+
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #111827; color: #f9fafb; }
|
|
281
|
+
main { max-width: 440px; margin: 12vh auto; padding: 28px; background: #020617; border: 1px solid #334155; border-radius: 12px; }
|
|
282
|
+
h1 { margin: 0 0 12px; font-size: 26px; }
|
|
283
|
+
p, dd { color: #cbd5e1; line-height: 1.5; }
|
|
284
|
+
dl { padding: 14px; background: #0f172a; border-radius: 10px; }
|
|
285
|
+
dt { color: #94a3b8; font-size: 12px; text-transform: uppercase; }
|
|
286
|
+
dd { margin: 4px 0 12px; word-break: break-word; }
|
|
287
|
+
label { display: block; margin: 18px 0 8px; font-weight: 700; }
|
|
288
|
+
input { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 8px; border: 1px solid #475569; background: #111827; color: #f9fafb; font-size: 16px; }
|
|
289
|
+
button { margin-top: 16px; width: 100%; border: 0; border-radius: 8px; padding: 12px; font-weight: 800; background: #38bdf8; color: #020617; }
|
|
290
|
+
.error { color: #fecaca; background: #7f1d1d; border-radius: 8px; padding: 10px; }
|
|
291
|
+
.warning { color: #fde68a; }
|
|
292
|
+
</style>
|
|
293
|
+
</head>
|
|
294
|
+
<body>
|
|
295
|
+
<main>
|
|
296
|
+
<h1>Connect Computer Linker</h1>
|
|
297
|
+
<p class="warning">Only approve this if you intentionally want this MCP client to control the configured workspaces on this computer.</p>
|
|
298
|
+
${error}
|
|
299
|
+
<dl>
|
|
300
|
+
<dt>Client</dt><dd>${htmlEscape(clientName)}</dd>
|
|
301
|
+
<dt>Scope</dt><dd>${htmlEscape(scopes)}</dd>
|
|
302
|
+
<dt>Resource</dt><dd>${htmlEscape(resource)}</dd>
|
|
303
|
+
</dl>
|
|
304
|
+
<form method="post">
|
|
305
|
+
${hiddenFields}
|
|
306
|
+
<label for="owner_token">Owner token</label>
|
|
307
|
+
<input id="owner_token" name="owner_token" type="password" autocomplete="current-password" required autofocus />
|
|
308
|
+
<button type="submit">Authorize Computer Linker</button>
|
|
309
|
+
</form>
|
|
310
|
+
</main>
|
|
311
|
+
</body>
|
|
312
|
+
</html>`;
|
|
313
|
+
}
|
|
314
|
+
function authorizationFormFields(client, params) {
|
|
315
|
+
return {
|
|
316
|
+
response_type: "code",
|
|
317
|
+
client_id: client.client_id,
|
|
318
|
+
redirect_uri: params.redirectUri,
|
|
319
|
+
code_challenge: params.codeChallenge,
|
|
320
|
+
code_challenge_method: "S256",
|
|
321
|
+
scope: params.scopes?.join(" "),
|
|
322
|
+
state: params.state,
|
|
323
|
+
resource: params.resource?.href,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface PackageMetadata {
|
|
2
|
+
name: string;
|
|
3
|
+
version: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function packageMetadata(): PackageMetadata;
|
|
6
|
+
export declare function computerLinkerVersion(): string;
|
|
7
|
+
export declare const workspaceLinkerVersion: typeof computerLinkerVersion;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
let cachedPackageMetadata;
|
|
5
|
+
export function packageMetadata() {
|
|
6
|
+
if (cachedPackageMetadata)
|
|
7
|
+
return cachedPackageMetadata;
|
|
8
|
+
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
9
|
+
try {
|
|
10
|
+
const value = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
11
|
+
cachedPackageMetadata = {
|
|
12
|
+
name: typeof value.name === "string" && value.name.trim() ? value.name : "@easonwumac/computer-linker",
|
|
13
|
+
version: typeof value.version === "string" && value.version.trim() ? value.version : "unknown",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
cachedPackageMetadata = { name: "@easonwumac/computer-linker", version: "unknown" };
|
|
18
|
+
}
|
|
19
|
+
return cachedPackageMetadata;
|
|
20
|
+
}
|
|
21
|
+
export function computerLinkerVersion() {
|
|
22
|
+
return packageMetadata().version;
|
|
23
|
+
}
|
|
24
|
+
export const workspaceLinkerVersion = computerLinkerVersion;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface PathPermissions {
|
|
2
|
+
read: boolean;
|
|
3
|
+
write: boolean;
|
|
4
|
+
shell: boolean;
|
|
5
|
+
codex: boolean;
|
|
6
|
+
screen?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkspacePolicy {
|
|
9
|
+
maxRuntimeSeconds?: number;
|
|
10
|
+
maxOutputBytes?: number;
|
|
11
|
+
allowedCommands?: string[];
|
|
12
|
+
deniedCommands?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface ExposedPathConfig {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
path: string;
|
|
18
|
+
permissions: PathPermissions;
|
|
19
|
+
policy?: WorkspacePolicy;
|
|
20
|
+
}
|
|
21
|
+
export interface LocalPortConfig {
|
|
22
|
+
machineId?: string;
|
|
23
|
+
machineName: string;
|
|
24
|
+
host?: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
publicBaseUrl?: string;
|
|
27
|
+
publicMcpOnly?: boolean;
|
|
28
|
+
ownerToken?: string;
|
|
29
|
+
workspaces: ExposedPathConfig[];
|
|
30
|
+
}
|
|
31
|
+
export interface ResolvedExposedPath extends ExposedPathConfig {
|
|
32
|
+
path: string;
|
|
33
|
+
}
|
|
34
|
+
export declare class PermissionDeniedError extends Error {
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
}
|
|
37
|
+
export declare function defaultConfig(): LocalPortConfig;
|
|
38
|
+
export declare function expandHomePath(path: string): string;
|
|
39
|
+
export declare function normalizeConfig(config: LocalPortConfig): LocalPortConfig;
|
|
40
|
+
export declare function generateMachineId(): string;
|
|
41
|
+
export declare function isPathInsideRoot(path: string, root: string): boolean;
|
|
42
|
+
export declare function findExposedPath(config: LocalPortConfig, path: string): ResolvedExposedPath;
|
|
43
|
+
export declare function assertPermission(exposedPath: ResolvedExposedPath, permission: keyof PathPermissions): void;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
export class PermissionDeniedError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "PermissionDeniedError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function defaultConfig() {
|
|
11
|
+
return {
|
|
12
|
+
machineId: generateMachineId(),
|
|
13
|
+
machineName: hostname().trim() || "local-computer",
|
|
14
|
+
host: "127.0.0.1",
|
|
15
|
+
port: 3939,
|
|
16
|
+
publicBaseUrl: undefined,
|
|
17
|
+
publicMcpOnly: false,
|
|
18
|
+
ownerToken: undefined,
|
|
19
|
+
workspaces: [
|
|
20
|
+
{
|
|
21
|
+
id: "current",
|
|
22
|
+
name: "Current directory",
|
|
23
|
+
path: process.cwd(),
|
|
24
|
+
permissions: {
|
|
25
|
+
read: true,
|
|
26
|
+
write: true,
|
|
27
|
+
shell: true,
|
|
28
|
+
codex: false,
|
|
29
|
+
screen: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function expandHomePath(path) {
|
|
36
|
+
if (path === "~")
|
|
37
|
+
return homedir();
|
|
38
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
39
|
+
return resolve(homedir(), path.slice(2));
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
export function normalizeConfig(config) {
|
|
44
|
+
const workspaces = config.workspaces.map((entry) => ({
|
|
45
|
+
...entry,
|
|
46
|
+
id: entry.id.trim(),
|
|
47
|
+
name: entry.name.trim() || entry.id.trim(),
|
|
48
|
+
path: resolve(expandHomePath(entry.path)),
|
|
49
|
+
permissions: {
|
|
50
|
+
read: Boolean(entry.permissions.read),
|
|
51
|
+
write: Boolean(entry.permissions.write),
|
|
52
|
+
shell: Boolean(entry.permissions.shell),
|
|
53
|
+
codex: Boolean(entry.permissions.codex),
|
|
54
|
+
screen: Boolean(entry.permissions.screen),
|
|
55
|
+
},
|
|
56
|
+
policy: normalizeWorkspacePolicy(entry.policy),
|
|
57
|
+
}));
|
|
58
|
+
assertUniqueWorkspaceIds(workspaces);
|
|
59
|
+
return {
|
|
60
|
+
machineId: config.machineId?.trim() || generateMachineId(),
|
|
61
|
+
machineName: config.machineName?.trim() || hostname().trim() || "local-computer",
|
|
62
|
+
host: config.host?.trim() || "127.0.0.1",
|
|
63
|
+
port: normalizePort(config.port),
|
|
64
|
+
publicBaseUrl: config.publicBaseUrl?.trim() || undefined,
|
|
65
|
+
publicMcpOnly: Boolean(config.publicMcpOnly),
|
|
66
|
+
ownerToken: config.ownerToken?.trim() || undefined,
|
|
67
|
+
workspaces,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function normalizeWorkspacePolicy(policy) {
|
|
71
|
+
if (!policy)
|
|
72
|
+
return undefined;
|
|
73
|
+
const normalized = {};
|
|
74
|
+
const maxRuntimeSeconds = normalizePositiveInteger(policy.maxRuntimeSeconds, 24 * 60 * 60);
|
|
75
|
+
const maxOutputBytes = normalizePositiveInteger(policy.maxOutputBytes, 10 * 1024 * 1024);
|
|
76
|
+
const allowedCommands = normalizeStringList(policy.allowedCommands);
|
|
77
|
+
const deniedCommands = normalizeStringList(policy.deniedCommands);
|
|
78
|
+
if (maxRuntimeSeconds !== undefined)
|
|
79
|
+
normalized.maxRuntimeSeconds = maxRuntimeSeconds;
|
|
80
|
+
if (maxOutputBytes !== undefined)
|
|
81
|
+
normalized.maxOutputBytes = maxOutputBytes;
|
|
82
|
+
if (allowedCommands.length > 0)
|
|
83
|
+
normalized.allowedCommands = allowedCommands;
|
|
84
|
+
if (deniedCommands.length > 0)
|
|
85
|
+
normalized.deniedCommands = deniedCommands;
|
|
86
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
87
|
+
}
|
|
88
|
+
function normalizePositiveInteger(value, max) {
|
|
89
|
+
return Number.isInteger(value) && value !== undefined && value > 0 ? Math.min(value, max) : undefined;
|
|
90
|
+
}
|
|
91
|
+
function normalizeStringList(value) {
|
|
92
|
+
if (!Array.isArray(value))
|
|
93
|
+
return [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
const normalized = [];
|
|
96
|
+
for (const item of value) {
|
|
97
|
+
const text = typeof item === "string" ? item.trim().replace(/\s+/g, " ") : "";
|
|
98
|
+
if (!text || seen.has(text))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(text);
|
|
101
|
+
normalized.push(text);
|
|
102
|
+
}
|
|
103
|
+
return normalized.slice(0, 100);
|
|
104
|
+
}
|
|
105
|
+
export function generateMachineId() {
|
|
106
|
+
return `machine_${randomUUID()}`;
|
|
107
|
+
}
|
|
108
|
+
function assertUniqueWorkspaceIds(workspaces) {
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const workspace of workspaces) {
|
|
111
|
+
if (!workspace.id)
|
|
112
|
+
throw new Error("Workspace id is required");
|
|
113
|
+
if (seen.has(workspace.id))
|
|
114
|
+
throw new Error(`Duplicate workspace id: ${workspace.id}`);
|
|
115
|
+
seen.add(workspace.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function normalizePort(port) {
|
|
119
|
+
if (port === undefined)
|
|
120
|
+
return 3939;
|
|
121
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
122
|
+
throw new Error(`Invalid port: ${port}`);
|
|
123
|
+
}
|
|
124
|
+
return port;
|
|
125
|
+
}
|
|
126
|
+
export function isPathInsideRoot(path, root) {
|
|
127
|
+
const resolvedPath = resolve(expandHomePath(path));
|
|
128
|
+
const resolvedRoot = resolve(expandHomePath(root));
|
|
129
|
+
const relationship = relative(resolvedRoot, resolvedPath);
|
|
130
|
+
return (relationship === "" ||
|
|
131
|
+
(!relationship.startsWith("..") && relationship !== ".." && !relationship.includes(`..${sep}`)));
|
|
132
|
+
}
|
|
133
|
+
export function findExposedPath(config, path) {
|
|
134
|
+
const resolvedPath = resolve(expandHomePath(path));
|
|
135
|
+
const match = config.workspaces
|
|
136
|
+
.filter((entry) => isPathInsideRoot(resolvedPath, entry.path))
|
|
137
|
+
.sort((a, b) => resolve(expandHomePath(b.path)).length - resolve(expandHomePath(a.path)).length)[0];
|
|
138
|
+
if (!match) {
|
|
139
|
+
throw new PermissionDeniedError(`Path is outside exposed paths: ${path}`);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...match,
|
|
143
|
+
path: resolve(expandHomePath(match.path)),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function assertPermission(exposedPath, permission) {
|
|
147
|
+
if (exposedPath.permissions[permission])
|
|
148
|
+
return;
|
|
149
|
+
throw new PermissionDeniedError(`${permission} permission is disabled for exposed path ${exposedPath.id} (${exposedPath.path})`);
|
|
150
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ShellCommand {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
windowsVerbatimArguments?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function shellCommand(command: string, options?: {
|
|
7
|
+
platform?: NodeJS.Platform;
|
|
8
|
+
shell?: string;
|
|
9
|
+
comSpec?: string;
|
|
10
|
+
}): ShellCommand;
|
|
11
|
+
export declare function resolveExecutableCommand(command: string, options?: {
|
|
12
|
+
platform?: NodeJS.Platform;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function executableCommand(command: string, args: string[], options?: {
|
|
16
|
+
platform?: NodeJS.Platform;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
shell?: string;
|
|
19
|
+
comSpec?: string;
|
|
20
|
+
}): ShellCommand;
|
|
21
|
+
export declare function windowsVerbatimArgumentsOption(command: ShellCommand): object;
|
|
22
|
+
export declare function findExecutableCommand(command: string, options?: {
|
|
23
|
+
platform?: NodeJS.Platform;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
}): string | undefined;
|
|
26
|
+
export declare function shouldRunExecutableThroughShell(command: string, options?: {
|
|
27
|
+
platform?: NodeJS.Platform;
|
|
28
|
+
}): boolean;
|