@cjhyy/code-shell-server 0.9.8 → 0.9.9
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 +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { CredentialStore, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
|
|
3
|
+
import { listDesktopLinkProviders } from "./catalog.js";
|
|
4
|
+
import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
|
|
5
|
+
const messages = {
|
|
6
|
+
invalid_request: "Link 请求参数无效。",
|
|
7
|
+
login_required: "登录已失效,请重新登录。",
|
|
8
|
+
not_found: "找不到这个 Link 连接或授权。",
|
|
9
|
+
read_only: "这个连接由项目配置管理,不能在这里修改。",
|
|
10
|
+
conflict: "连接已被修改或删除,请刷新后重试。",
|
|
11
|
+
busy: "请等待当前任务或连接操作完成后重试。",
|
|
12
|
+
provider_rejected: "服务商未能验证凭证,请检查授权与访问权限。",
|
|
13
|
+
cli_unavailable: "当前运行环境中没有可绑定的 CLI 登录,请先登录或使用 Token。",
|
|
14
|
+
authorization_failed: "授权未完成,请重新开始。",
|
|
15
|
+
authorization_expired: "授权已过期,请重新开始。",
|
|
16
|
+
cancelled: "授权已取消。",
|
|
17
|
+
unavailable: "Link 服务暂时不可用,请刷新后重试。",
|
|
18
|
+
};
|
|
19
|
+
export class LinkServiceError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
code;
|
|
22
|
+
constructor(status, code) {
|
|
23
|
+
super(messages[code]);
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.name = "LinkServiceError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function publicLinkError(error) {
|
|
30
|
+
if (error instanceof LinkServiceError)
|
|
31
|
+
return error;
|
|
32
|
+
const status = error?.status;
|
|
33
|
+
return status === 409
|
|
34
|
+
? new LinkServiceError(409, "busy")
|
|
35
|
+
: status === 401
|
|
36
|
+
? new LinkServiceError(401, "login_required")
|
|
37
|
+
: new LinkServiceError(503, "unavailable");
|
|
38
|
+
}
|
|
39
|
+
/** One host/workspace credential domain. Browsers never provide the store or owner identity. */
|
|
40
|
+
export function createLinkService(options = {}) {
|
|
41
|
+
const store = options.store ?? new CredentialStore(options.cwd);
|
|
42
|
+
const now = options.now ?? Date.now;
|
|
43
|
+
const validateToken = options.validateToken ?? validateLocalLinkToken;
|
|
44
|
+
const cliStatus = options.cliStatus ?? getCliLinkStatus;
|
|
45
|
+
const bindCli = options.bindCli ?? connectCliLink;
|
|
46
|
+
const createBroker = options.createDeviceBroker ?? (() => new LinkDeviceOAuthBroker());
|
|
47
|
+
const providers = listDesktopLinkProviders();
|
|
48
|
+
const revisions = new Map();
|
|
49
|
+
const operations = new Set();
|
|
50
|
+
const jobs = new Map();
|
|
51
|
+
const revokedOwners = new Set();
|
|
52
|
+
let closed = false;
|
|
53
|
+
let snapshotSignature = "";
|
|
54
|
+
let snapshotRevision = randomUUID();
|
|
55
|
+
function unavailable(context) {
|
|
56
|
+
if (closed)
|
|
57
|
+
throw new LinkServiceError(503, "unavailable");
|
|
58
|
+
if (!context.ownerId || revokedOwners.has(context.ownerId))
|
|
59
|
+
throw new LinkServiceError(401, "login_required");
|
|
60
|
+
}
|
|
61
|
+
async function authorize(context) {
|
|
62
|
+
unavailable(context);
|
|
63
|
+
if (!(await context.authorize()))
|
|
64
|
+
throw new LinkServiceError(401, "login_required");
|
|
65
|
+
unavailable(context);
|
|
66
|
+
}
|
|
67
|
+
function begin(context) {
|
|
68
|
+
unavailable(context);
|
|
69
|
+
if (operations.size >= 8 ||
|
|
70
|
+
[...operations].filter((operation) => operation.ownerId === context.ownerId).length >= 2)
|
|
71
|
+
throw new LinkServiceError(409, "busy");
|
|
72
|
+
const operation = { ownerId: context.ownerId, controller: new AbortController() };
|
|
73
|
+
operations.add(operation);
|
|
74
|
+
return operation;
|
|
75
|
+
}
|
|
76
|
+
function finish(operation) {
|
|
77
|
+
operations.delete(operation);
|
|
78
|
+
}
|
|
79
|
+
function revision(credential) {
|
|
80
|
+
const signature = createHash("sha256").update(JSON.stringify(credential)).digest("hex");
|
|
81
|
+
let current = revisions.get(credential.id);
|
|
82
|
+
if (current?.signature !== signature) {
|
|
83
|
+
current = { signature, revision: randomUUID() };
|
|
84
|
+
revisions.set(credential.id, current);
|
|
85
|
+
}
|
|
86
|
+
return current.revision;
|
|
87
|
+
}
|
|
88
|
+
function records() {
|
|
89
|
+
const current = store.listWithStatus("full");
|
|
90
|
+
if (!current.readable)
|
|
91
|
+
throw new LinkServiceError(503, "unavailable");
|
|
92
|
+
return {
|
|
93
|
+
credentials: current.credentials,
|
|
94
|
+
projectIds: new Set(store.list("project").map((c) => c.id)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function isLink(credential) {
|
|
98
|
+
return (credential.type === "link" &&
|
|
99
|
+
credential.meta?.linkExecutionRuntime === "local" &&
|
|
100
|
+
providers.some((provider) => provider.id === credential.meta?.linkProvider));
|
|
101
|
+
}
|
|
102
|
+
function masked(credential, scope) {
|
|
103
|
+
const meta = credential.meta;
|
|
104
|
+
const oauth = meta.linkAuthSource === "browser-oauth"
|
|
105
|
+
? summarizeOAuthCredentialSecret(credential.secret)
|
|
106
|
+
: undefined;
|
|
107
|
+
const status = !isCredentialSecretAvailable(credential.secret)
|
|
108
|
+
? "unavailable"
|
|
109
|
+
: oauth?.state === "expired"
|
|
110
|
+
? "expired"
|
|
111
|
+
: oauth?.state === "invalid" || oauth?.state === "missing"
|
|
112
|
+
? "invalid"
|
|
113
|
+
: "connected";
|
|
114
|
+
return {
|
|
115
|
+
id: credential.id,
|
|
116
|
+
providerId: meta.linkProvider,
|
|
117
|
+
methodId: meta.linkConnectionMethod ?? "",
|
|
118
|
+
label: credential.label,
|
|
119
|
+
runtime: "local",
|
|
120
|
+
authSource: meta.linkExecutionBackend === "cli"
|
|
121
|
+
? "cli-session"
|
|
122
|
+
: meta.linkAuthSource === "browser-oauth"
|
|
123
|
+
? "browser-oauth"
|
|
124
|
+
: "manual-token",
|
|
125
|
+
status,
|
|
126
|
+
scope,
|
|
127
|
+
editable: scope === "user",
|
|
128
|
+
revision: revision(credential),
|
|
129
|
+
account: {
|
|
130
|
+
id: meta.linkAccountId,
|
|
131
|
+
label: meta.linkAccountLabel,
|
|
132
|
+
resources: meta.linkResourceLabels?.slice(0, 100) ?? [],
|
|
133
|
+
},
|
|
134
|
+
capabilityIds: meta.linkCapabilityIds ?? [],
|
|
135
|
+
verifiedAt: meta.linkLastVerifiedAt,
|
|
136
|
+
...(oauth?.expiresAt ? { expiresAt: oauth.expiresAt } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function snapshot() {
|
|
140
|
+
if (closed)
|
|
141
|
+
throw new LinkServiceError(503, "unavailable");
|
|
142
|
+
const current = records();
|
|
143
|
+
const ids = new Set(current.credentials.map((credential) => credential.id));
|
|
144
|
+
for (const id of revisions.keys())
|
|
145
|
+
if (!ids.has(id))
|
|
146
|
+
revisions.delete(id);
|
|
147
|
+
const result = {
|
|
148
|
+
providers: providers.map((provider) => ({
|
|
149
|
+
...provider,
|
|
150
|
+
...(isLocalBrowserLinkProvider(provider.id)
|
|
151
|
+
? { deviceAuth: createBroker().status(provider.id) }
|
|
152
|
+
: {}),
|
|
153
|
+
})),
|
|
154
|
+
connections: current.credentials
|
|
155
|
+
.filter(isLink)
|
|
156
|
+
.map((credential) => masked(credential, current.projectIds.has(credential.id) ? "project" : "user")),
|
|
157
|
+
capabilities: { token: true, cliBinding: true, deviceAuth: true },
|
|
158
|
+
};
|
|
159
|
+
const signature = JSON.stringify(result);
|
|
160
|
+
if (signature !== snapshotSignature) {
|
|
161
|
+
snapshotSignature = signature;
|
|
162
|
+
snapshotRevision = randomUUID();
|
|
163
|
+
}
|
|
164
|
+
return { ...result, revision: snapshotRevision };
|
|
165
|
+
}
|
|
166
|
+
function string(value, max = 200) {
|
|
167
|
+
if (typeof value !== "string" ||
|
|
168
|
+
!value.trim() ||
|
|
169
|
+
value.length > max ||
|
|
170
|
+
/[\x00-\x1f\x7f]/.test(value))
|
|
171
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
172
|
+
return value.trim();
|
|
173
|
+
}
|
|
174
|
+
function safeId(value) {
|
|
175
|
+
const id = string(value, 128);
|
|
176
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
177
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
178
|
+
return id;
|
|
179
|
+
}
|
|
180
|
+
function reviewed(id, expectedRevision) {
|
|
181
|
+
const current = records();
|
|
182
|
+
const credential = current.credentials.find((entry) => entry.id === id);
|
|
183
|
+
if (!credential || !isLink(credential))
|
|
184
|
+
throw new LinkServiceError(409, "conflict");
|
|
185
|
+
if (current.projectIds.has(id))
|
|
186
|
+
throw new LinkServiceError(403, "read_only");
|
|
187
|
+
if (revision(credential) !== string(expectedRevision, 64))
|
|
188
|
+
throw new LinkServiceError(409, "conflict");
|
|
189
|
+
return credential;
|
|
190
|
+
}
|
|
191
|
+
function prepare(raw) {
|
|
192
|
+
if (!raw || typeof raw !== "object")
|
|
193
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
194
|
+
const input = {
|
|
195
|
+
providerId: string(raw.providerId, 80),
|
|
196
|
+
methodId: string(raw.methodId, 80),
|
|
197
|
+
label: string(raw.label),
|
|
198
|
+
expectedRevision: raw.expectedRevision,
|
|
199
|
+
...(raw.connectionId === undefined ? {} : { connectionId: safeId(raw.connectionId) }),
|
|
200
|
+
};
|
|
201
|
+
const method = providers
|
|
202
|
+
.find((provider) => provider.id === input.providerId)
|
|
203
|
+
?.connectionMethods.find((candidate) => candidate.id === input.methodId);
|
|
204
|
+
if (!method || method.executionRuntime !== "local" || method.availability !== "available")
|
|
205
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
206
|
+
const id = input.connectionId ?? `link-${input.providerId}-${input.methodId}`;
|
|
207
|
+
let expected = null;
|
|
208
|
+
if (input.expectedRevision === null) {
|
|
209
|
+
if (records().credentials.some((credential) => credential.id === id))
|
|
210
|
+
throw new LinkServiceError(409, "conflict");
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
expected = reviewed(id, input.expectedRevision);
|
|
214
|
+
if (expected.meta?.linkProvider !== input.providerId ||
|
|
215
|
+
expected.meta.linkConnectionMethod !== input.methodId)
|
|
216
|
+
throw new LinkServiceError(409, "conflict");
|
|
217
|
+
}
|
|
218
|
+
return { input, id, expected };
|
|
219
|
+
}
|
|
220
|
+
async function change(context, write, bypassBusy = false) {
|
|
221
|
+
const action = async () => {
|
|
222
|
+
await authorize(context);
|
|
223
|
+
const result = write();
|
|
224
|
+
await options.onChanged?.();
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
227
|
+
const result = !bypassBusy && options.withMutation ? await options.withMutation(action) : await action();
|
|
228
|
+
await authorize(context);
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
function swap(prepared, next) {
|
|
232
|
+
const current = records();
|
|
233
|
+
if (current.projectIds.has(prepared.id))
|
|
234
|
+
throw new LinkServiceError(403, "read_only");
|
|
235
|
+
if (!store.compareAndSwap("user", prepared.id, prepared.expected, next))
|
|
236
|
+
throw new LinkServiceError(409, "conflict");
|
|
237
|
+
}
|
|
238
|
+
async function commit(context, prepared, validation, secret, authSource) {
|
|
239
|
+
const credential = {
|
|
240
|
+
id: prepared.id,
|
|
241
|
+
type: "link",
|
|
242
|
+
label: prepared.input.label,
|
|
243
|
+
secret,
|
|
244
|
+
autoUseByAI: false,
|
|
245
|
+
meta: {
|
|
246
|
+
linkProvider: prepared.input.providerId,
|
|
247
|
+
linkConnectionMethod: prepared.input.methodId,
|
|
248
|
+
linkExecutionRuntime: "local",
|
|
249
|
+
linkAuthSource: authSource,
|
|
250
|
+
linkExecutionBackend: authSource === "cli-session" ? "cli" : "http-token",
|
|
251
|
+
agentExposable: false,
|
|
252
|
+
linkAccountId: validation.identity.externalAccountId,
|
|
253
|
+
linkAccountLabel: validation.identity.label,
|
|
254
|
+
linkResourceLabels: validation.identity.resourceLabels,
|
|
255
|
+
linkCapabilityIds: validation.capabilityIds,
|
|
256
|
+
linkLastVerifiedAt: new Date(Math.max(Date.parse(validation.verifiedAt) || now(), (Date.parse(prepared.expected?.meta?.linkLastVerifiedAt ?? "") || 0) + 1)).toISOString(),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
return change(context, () => {
|
|
260
|
+
swap(prepared, credential);
|
|
261
|
+
return masked(credential, "user");
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function tokenSecret(token) {
|
|
265
|
+
return JSON.stringify({
|
|
266
|
+
version: 1,
|
|
267
|
+
accessToken: token.accessToken,
|
|
268
|
+
refreshToken: token.refreshToken,
|
|
269
|
+
expiresAt: token.expiresIn === undefined
|
|
270
|
+
? undefined
|
|
271
|
+
: new Date(now() + token.expiresIn * 1000).toISOString(),
|
|
272
|
+
refreshTokenExpiresAt: token.refreshTokenExpiresIn === undefined
|
|
273
|
+
? undefined
|
|
274
|
+
: new Date(now() + token.refreshTokenExpiresIn * 1000).toISOString(),
|
|
275
|
+
tokenType: token.tokenType,
|
|
276
|
+
scope: token.scope,
|
|
277
|
+
tokenEndpoint: token.tokenEndpoint,
|
|
278
|
+
clientId: token.clientId,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function persistValidatedConnection(context, input, validation, authentication) {
|
|
282
|
+
await authorize(context);
|
|
283
|
+
const prepared = prepare(input);
|
|
284
|
+
if (validation.providerId !== prepared.input.providerId)
|
|
285
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
286
|
+
const provider = providers.find((entry) => entry.id === prepared.input.providerId);
|
|
287
|
+
const method = provider.connectionMethods.find((entry) => entry.id === prepared.input.methodId);
|
|
288
|
+
if (validation.capabilityIds.some((id) => !provider.actions.some((action) => id === `${provider.id}.${action.id}`)))
|
|
289
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
290
|
+
if (authentication.source === "cli-session" && !method.quickAuth)
|
|
291
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
292
|
+
if (authentication.source === "browser-oauth" &&
|
|
293
|
+
(!method.browserAuth || authentication.token.providerId !== provider.id))
|
|
294
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
295
|
+
const secret = authentication.source === "cli-session"
|
|
296
|
+
? `cli-binding:${randomBytes(24).toString("base64url")}`
|
|
297
|
+
: authentication.source === "browser-oauth"
|
|
298
|
+
? tokenSecret(authentication.token)
|
|
299
|
+
: string(authentication.token, 16_384);
|
|
300
|
+
return commit(context, prepared, validation, secret, authentication.source);
|
|
301
|
+
}
|
|
302
|
+
async function connectToken(context, input) {
|
|
303
|
+
const operation = begin(context);
|
|
304
|
+
try {
|
|
305
|
+
await authorize(context);
|
|
306
|
+
const prepared = prepare(input);
|
|
307
|
+
const token = string(input.token, 16_384);
|
|
308
|
+
let validation;
|
|
309
|
+
try {
|
|
310
|
+
validation = await validateToken(prepared.input.providerId, token, {
|
|
311
|
+
signal: operation.controller.signal,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
await authorize(context);
|
|
316
|
+
throw new LinkServiceError(422, "provider_rejected");
|
|
317
|
+
}
|
|
318
|
+
await authorize(context);
|
|
319
|
+
return await commit(context, prepared, validation, token, "manual-token");
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
finish(operation);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function connectCli(context, input) {
|
|
326
|
+
const operation = begin(context);
|
|
327
|
+
try {
|
|
328
|
+
await authorize(context);
|
|
329
|
+
const prepared = prepare(input);
|
|
330
|
+
const providerId = prepared.input.providerId;
|
|
331
|
+
const method = providers
|
|
332
|
+
.find((provider) => provider.id === providerId)
|
|
333
|
+
?.connectionMethods.find((candidate) => candidate.id === prepared.input.methodId);
|
|
334
|
+
if (!isCliLinkProvider(providerId) || !method?.quickAuth)
|
|
335
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
336
|
+
let validation;
|
|
337
|
+
try {
|
|
338
|
+
validation = await bindCli(providerId, {
|
|
339
|
+
cwd: options.cwd,
|
|
340
|
+
signal: operation.controller.signal,
|
|
341
|
+
loginIfNeeded: false,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
await authorize(context);
|
|
346
|
+
throw new LinkServiceError(422, "cli_unavailable");
|
|
347
|
+
}
|
|
348
|
+
await authorize(context);
|
|
349
|
+
return await commit(context, prepared, validation, `cli-binding:${randomBytes(24).toString("base64url")}`, "cli-session");
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
finish(operation);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function getCliStatus(context, rawProviderId) {
|
|
356
|
+
const operation = begin(context);
|
|
357
|
+
try {
|
|
358
|
+
await authorize(context);
|
|
359
|
+
const providerId = string(rawProviderId, 80);
|
|
360
|
+
if (!isCliLinkProvider(providerId))
|
|
361
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
362
|
+
const result = await cliStatus(providerId, {
|
|
363
|
+
cwd: options.cwd,
|
|
364
|
+
signal: operation.controller.signal,
|
|
365
|
+
});
|
|
366
|
+
await authorize(context);
|
|
367
|
+
return {
|
|
368
|
+
providerId,
|
|
369
|
+
command: result.command,
|
|
370
|
+
installed: result.installed,
|
|
371
|
+
authenticated: result.authenticated,
|
|
372
|
+
...(result.account ? { account: result.account } : {}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
finish(operation);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function rename(context, id, label, expectedRevision) {
|
|
380
|
+
await authorize(context);
|
|
381
|
+
const credential = reviewed(safeId(id), expectedRevision);
|
|
382
|
+
const replacement = { ...credential, label: string(label) };
|
|
383
|
+
return change(context, () => {
|
|
384
|
+
swap({ id: credential.id, expected: credential, input: {} }, replacement);
|
|
385
|
+
return masked(replacement, "user");
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async function disconnect(context, id, expectedRevision) {
|
|
389
|
+
await authorize(context);
|
|
390
|
+
const credential = reviewed(safeId(id), expectedRevision);
|
|
391
|
+
// Revocation must work during an active action; the host's credential observer aborts it.
|
|
392
|
+
await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
|
|
393
|
+
}
|
|
394
|
+
function cancelJob(job) {
|
|
395
|
+
job.operation.controller.abort();
|
|
396
|
+
if (job.brokerAttempt)
|
|
397
|
+
job.broker.cancel(job.brokerAttempt);
|
|
398
|
+
if (job.timer)
|
|
399
|
+
clearTimeout(job.timer);
|
|
400
|
+
if (job.public.state === "pending")
|
|
401
|
+
job.public = {
|
|
402
|
+
id: job.public.id,
|
|
403
|
+
providerId: job.public.providerId,
|
|
404
|
+
state: "cancelled",
|
|
405
|
+
errorCode: "cancelled",
|
|
406
|
+
};
|
|
407
|
+
finish(job.operation);
|
|
408
|
+
job.expiresAt = now() + 5 * 60_000;
|
|
409
|
+
}
|
|
410
|
+
function expireJob(job) {
|
|
411
|
+
if (job.public.state !== "pending")
|
|
412
|
+
return;
|
|
413
|
+
cancelJob(job);
|
|
414
|
+
job.public = {
|
|
415
|
+
id: job.public.id,
|
|
416
|
+
providerId: job.public.providerId,
|
|
417
|
+
state: "failed",
|
|
418
|
+
errorCode: "authorization_expired",
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function pruneJobs() {
|
|
422
|
+
for (const [id, job] of jobs)
|
|
423
|
+
if (job.expiresAt <= now()) {
|
|
424
|
+
if (job.public.state === "pending")
|
|
425
|
+
expireJob(job);
|
|
426
|
+
else
|
|
427
|
+
jobs.delete(id);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async function completeDevice(job, prepared) {
|
|
431
|
+
try {
|
|
432
|
+
const token = await job.broker.complete(job.brokerAttempt);
|
|
433
|
+
await authorize(job.context);
|
|
434
|
+
if (job.operation.controller.signal.aborted || job.public.state !== "pending")
|
|
435
|
+
throw new LinkServiceError(409, "cancelled");
|
|
436
|
+
if (token.providerId !== prepared.input.providerId)
|
|
437
|
+
throw new LinkServiceError(422, "authorization_failed");
|
|
438
|
+
const validation = await validateToken(token.providerId, token.accessToken, {
|
|
439
|
+
signal: job.operation.controller.signal,
|
|
440
|
+
});
|
|
441
|
+
await authorize(job.context);
|
|
442
|
+
if (job.operation.controller.signal.aborted || job.public.state !== "pending")
|
|
443
|
+
throw new LinkServiceError(409, "cancelled");
|
|
444
|
+
const connection = await commit({
|
|
445
|
+
...job.context,
|
|
446
|
+
authorize: async () => (await job.context.authorize()) &&
|
|
447
|
+
!job.operation.controller.signal.aborted &&
|
|
448
|
+
job.public.state === "pending",
|
|
449
|
+
}, prepared, validation, tokenSecret(token), "browser-oauth");
|
|
450
|
+
if (job.public.state === "pending")
|
|
451
|
+
job.public = {
|
|
452
|
+
id: job.public.id,
|
|
453
|
+
providerId: job.public.providerId,
|
|
454
|
+
state: "connected",
|
|
455
|
+
connection,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (job.public.state === "pending")
|
|
460
|
+
job.public = {
|
|
461
|
+
id: job.public.id,
|
|
462
|
+
providerId: job.public.providerId,
|
|
463
|
+
state: "failed",
|
|
464
|
+
errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
if (job.timer)
|
|
469
|
+
clearTimeout(job.timer);
|
|
470
|
+
job.expiresAt = now() + 5 * 60_000;
|
|
471
|
+
finish(job.operation);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function startDeviceAuth(context, input) {
|
|
475
|
+
pruneJobs();
|
|
476
|
+
if (jobs.size >= 32)
|
|
477
|
+
throw new LinkServiceError(409, "busy");
|
|
478
|
+
const operation = begin(context);
|
|
479
|
+
let job;
|
|
480
|
+
try {
|
|
481
|
+
await authorize(context);
|
|
482
|
+
const prepared = prepare(input);
|
|
483
|
+
const providerId = prepared.input.providerId;
|
|
484
|
+
if (!isLocalBrowserLinkProvider(providerId))
|
|
485
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
486
|
+
const broker = createBroker();
|
|
487
|
+
if (!broker.status(providerId).configured)
|
|
488
|
+
throw new LinkServiceError(422, "authorization_failed");
|
|
489
|
+
const id = randomUUID();
|
|
490
|
+
job = {
|
|
491
|
+
ownerId: context.ownerId,
|
|
492
|
+
context,
|
|
493
|
+
broker,
|
|
494
|
+
public: { id, providerId, state: "pending" },
|
|
495
|
+
expiresAt: now() + 30 * 60_000,
|
|
496
|
+
operation,
|
|
497
|
+
};
|
|
498
|
+
jobs.set(id, job);
|
|
499
|
+
const prompt = await broker.start(providerId, operation.controller.signal);
|
|
500
|
+
job.brokerAttempt = prompt.attemptId;
|
|
501
|
+
await authorize(context);
|
|
502
|
+
if (operation.controller.signal.aborted)
|
|
503
|
+
throw new LinkServiceError(409, "cancelled");
|
|
504
|
+
job.expiresAt = Date.parse(prompt.expiresAt);
|
|
505
|
+
job.public.prompt = {
|
|
506
|
+
userCode: prompt.userCode,
|
|
507
|
+
verificationUri: prompt.verificationUri,
|
|
508
|
+
verificationUriComplete: prompt.verificationUriComplete,
|
|
509
|
+
expiresAt: prompt.expiresAt,
|
|
510
|
+
};
|
|
511
|
+
job.timer = setTimeout(() => expireJob(job), Math.max(1, Math.min(30 * 60_000, job.expiresAt - now())));
|
|
512
|
+
job.timer.unref?.();
|
|
513
|
+
const result = structuredClone(job.public);
|
|
514
|
+
void completeDevice(job, prepared);
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (job) {
|
|
519
|
+
cancelJob(job);
|
|
520
|
+
jobs.delete(job.public.id);
|
|
521
|
+
}
|
|
522
|
+
finish(operation);
|
|
523
|
+
throw publicLinkError(error);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function authorization(context, id) {
|
|
527
|
+
await authorize(context);
|
|
528
|
+
pruneJobs();
|
|
529
|
+
const job = jobs.get(safeId(id));
|
|
530
|
+
if (!job || job.ownerId !== context.ownerId)
|
|
531
|
+
throw new LinkServiceError(404, "not_found");
|
|
532
|
+
return structuredClone(job.public);
|
|
533
|
+
}
|
|
534
|
+
async function cancelAuthorization(context, id) {
|
|
535
|
+
await authorization(context, id);
|
|
536
|
+
const job = jobs.get(id);
|
|
537
|
+
if (job)
|
|
538
|
+
cancelJob(job);
|
|
539
|
+
await authorize(context);
|
|
540
|
+
}
|
|
541
|
+
function cancelOwner(ownerId) {
|
|
542
|
+
revokedOwners.add(ownerId);
|
|
543
|
+
for (const operation of operations)
|
|
544
|
+
if (operation.ownerId === ownerId) {
|
|
545
|
+
operation.controller.abort();
|
|
546
|
+
finish(operation);
|
|
547
|
+
}
|
|
548
|
+
for (const [id, job] of jobs)
|
|
549
|
+
if (job.ownerId === ownerId) {
|
|
550
|
+
cancelJob(job);
|
|
551
|
+
jobs.delete(id);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function close() {
|
|
555
|
+
closed = true;
|
|
556
|
+
for (const operation of operations)
|
|
557
|
+
operation.controller.abort();
|
|
558
|
+
operations.clear();
|
|
559
|
+
for (const job of jobs.values())
|
|
560
|
+
cancelJob(job);
|
|
561
|
+
jobs.clear();
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
snapshot,
|
|
565
|
+
persistValidatedConnection,
|
|
566
|
+
assertAuthorized: authorize,
|
|
567
|
+
connectToken,
|
|
568
|
+
connectCli,
|
|
569
|
+
cliStatus: getCliStatus,
|
|
570
|
+
rename,
|
|
571
|
+
disconnect,
|
|
572
|
+
startDeviceAuth,
|
|
573
|
+
authorization,
|
|
574
|
+
cancelAuthorization,
|
|
575
|
+
cancelOwner,
|
|
576
|
+
close,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { LinkAuthorization, LinkConnectionInput, LinkErrorCode, LinkSnapshot, MaskedLinkConnection, TokenConnectionInput, } from "@cjhyy/code-shell-link";
|
|
2
|
+
export interface LinkOperationContext {
|
|
3
|
+
ownerId: string;
|
|
4
|
+
/** Rechecked after every external await and immediately before persistence. */
|
|
5
|
+
authorize: () => boolean | Promise<boolean>;
|
|
6
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -46,7 +46,8 @@ export function parseMobileClientEvent(value) {
|
|
|
46
46
|
break;
|
|
47
47
|
case "session.create":
|
|
48
48
|
valid =
|
|
49
|
-
(event.
|
|
49
|
+
optionalBoundedString(event.clientRequestId, MAX_ID_LENGTH) &&
|
|
50
|
+
(event.cwd === undefined || event.cwd === null || boundedPath(event.cwd)) &&
|
|
50
51
|
(event.projectId === undefined ||
|
|
51
52
|
event.projectId === null ||
|
|
52
53
|
boundedString(event.projectId, MAX_ID_LENGTH)) &&
|
|
@@ -75,7 +76,9 @@ export function parseMobileClientEvent(value) {
|
|
|
75
76
|
break;
|
|
76
77
|
case "session.sync":
|
|
77
78
|
valid =
|
|
78
|
-
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
79
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
80
|
+
optionalNonNegativeInteger(event.sinceSeq) &&
|
|
81
|
+
(event.epoch === undefined || boundedTrimmedString(event.epoch, MAX_ID_LENGTH));
|
|
79
82
|
break;
|
|
80
83
|
case "permission.setMode":
|
|
81
84
|
valid =
|
|
@@ -4,6 +4,7 @@ import type { AccessPasscode } from "./access-passcode.js";
|
|
|
4
4
|
import type { TrustedDeviceStore } from "./trusted-device-store.js";
|
|
5
5
|
import type { MobileServerEvent } from "./types.js";
|
|
6
6
|
import type { MobileUploadService } from "./mobile-upload-service.js";
|
|
7
|
+
import type { DesktopWebHttpApi } from "../desktop-web/http-api.js";
|
|
7
8
|
/**
|
|
8
9
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
9
10
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -46,6 +47,8 @@ export interface RemoteHostManagerOptions {
|
|
|
46
47
|
mobileDevUrl?: string;
|
|
47
48
|
/** One-time upload tickets. The route remains behind the tunnel passcode gate. */
|
|
48
49
|
uploads?: Pick<MobileUploadService, "acceptPut" | "cancelActiveTransfers">;
|
|
50
|
+
/** Optional shared Web HTTP facade. Pairing, uploads, and /mobile retain their routes. */
|
|
51
|
+
webApi?: DesktopWebHttpApi;
|
|
49
52
|
}
|
|
50
53
|
export declare class RemoteHostManager extends EventEmitter {
|
|
51
54
|
private readonly opts;
|
|
@@ -53,6 +56,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
53
56
|
private wss?;
|
|
54
57
|
private started?;
|
|
55
58
|
private starting?;
|
|
59
|
+
private stopping?;
|
|
56
60
|
private pairing;
|
|
57
61
|
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
58
62
|
private authed;
|
|
@@ -114,6 +118,10 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
114
118
|
* a half-paired client never sees session output. */
|
|
115
119
|
broadcastRaw(payload: string): void;
|
|
116
120
|
private releaseSocket;
|
|
121
|
+
private bindSocketDevice;
|
|
122
|
+
/** Call after revoking/removing a trusted device to release both transports immediately. */
|
|
123
|
+
revokeDevice(deviceId: string): void;
|
|
117
124
|
stop(): Promise<void>;
|
|
125
|
+
private stopOnce;
|
|
118
126
|
status(): RemoteHostStarted | undefined;
|
|
119
127
|
}
|