@cjhyy/code-shell-server 0.9.7 → 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,255 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHardenedOAuthFetch } from "@cjhyy/code-shell-core";
|
|
3
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
4
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
5
|
+
const PROVIDERS = {
|
|
6
|
+
github: {
|
|
7
|
+
clientIdEnvironmentKey: "CODESHELL_GITHUB_APP_CLIENT_ID",
|
|
8
|
+
deviceEndpoint: "https://github.com/login/device/code",
|
|
9
|
+
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
10
|
+
verificationHosts: ["github.com"],
|
|
11
|
+
// A GitHub App user token uses the App's fine-grained permissions rather
|
|
12
|
+
// than OAuth scopes, so no scope parameter is sent.
|
|
13
|
+
},
|
|
14
|
+
gitlab: {
|
|
15
|
+
clientIdEnvironmentKey: "CODESHELL_GITLAB_OAUTH_CLIENT_ID",
|
|
16
|
+
deviceEndpoint: "https://gitlab.com/oauth/authorize_device",
|
|
17
|
+
tokenEndpoint: "https://gitlab.com/oauth/token",
|
|
18
|
+
verificationHosts: ["gitlab.com"],
|
|
19
|
+
scopes: ["read_api"],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
function abortableSleep(milliseconds, signal) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
if (signal.aborted) {
|
|
25
|
+
reject(new Error("Browser login cancelled"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const timer = setTimeout(done, milliseconds);
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
signal.removeEventListener("abort", onAbort);
|
|
32
|
+
reject(new Error("Browser login cancelled"));
|
|
33
|
+
};
|
|
34
|
+
function done() {
|
|
35
|
+
signal.removeEventListener("abort", onAbort);
|
|
36
|
+
resolve();
|
|
37
|
+
}
|
|
38
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function form(values) {
|
|
42
|
+
const body = new URLSearchParams();
|
|
43
|
+
for (const [key, value] of Object.entries(values)) {
|
|
44
|
+
if (value)
|
|
45
|
+
body.set(key, value);
|
|
46
|
+
}
|
|
47
|
+
return body;
|
|
48
|
+
}
|
|
49
|
+
async function safeJson(response, action, allowOAuthError = false) {
|
|
50
|
+
const raw = await response.text();
|
|
51
|
+
if (raw.length > MAX_RESPONSE_BYTES)
|
|
52
|
+
throw new Error(`${action} response is too large`);
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new Error(`${action} returned invalid JSON`, { cause: error });
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
61
|
+
throw new Error(`${action} returned an invalid response`);
|
|
62
|
+
}
|
|
63
|
+
const record = parsed;
|
|
64
|
+
if (!response.ok && !(allowOAuthError && typeof record.error === "string")) {
|
|
65
|
+
throw new Error(`${action} failed (${response.status})`);
|
|
66
|
+
}
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
function requiredString(value, field, action, maxLength = 16_384) {
|
|
70
|
+
if (typeof value !== "string" || !value || value.length > maxLength) {
|
|
71
|
+
throw new Error(`${action} response is missing ${field}`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function finiteSeconds(value, fallback, maximum) {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
77
|
+
? Math.min(value, maximum)
|
|
78
|
+
: fallback;
|
|
79
|
+
}
|
|
80
|
+
function verifiedHttpsUrl(raw, field, allowedHosts) {
|
|
81
|
+
const value = requiredString(raw, field, "Device authorization", 2_048);
|
|
82
|
+
let url;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(value);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Device authorization returned an invalid ${field}`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
if (url.protocol !== "https:" || !allowedHosts.includes(url.hostname.toLowerCase())) {
|
|
90
|
+
throw new Error(`Device authorization returned an untrusted ${field}`);
|
|
91
|
+
}
|
|
92
|
+
return url.toString();
|
|
93
|
+
}
|
|
94
|
+
export function isLocalBrowserLinkProvider(value) {
|
|
95
|
+
return value === "github" || value === "gitlab";
|
|
96
|
+
}
|
|
97
|
+
/** Main-process-only broker. Provider tokens never cross the renderer IPC. */
|
|
98
|
+
export class LinkDeviceOAuthBroker {
|
|
99
|
+
fetchFn;
|
|
100
|
+
now;
|
|
101
|
+
sleep;
|
|
102
|
+
createId;
|
|
103
|
+
clientIds;
|
|
104
|
+
environment;
|
|
105
|
+
attempts = new Map();
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.fetchFn = createHardenedOAuthFetch(options.fetch ?? fetch);
|
|
108
|
+
this.now = options.now ?? Date.now;
|
|
109
|
+
this.sleep = options.sleep ?? abortableSleep;
|
|
110
|
+
this.createId = options.createId ?? randomUUID;
|
|
111
|
+
this.clientIds = options.clientIds ?? {};
|
|
112
|
+
this.environment = options.environment ?? process.env;
|
|
113
|
+
}
|
|
114
|
+
status(providerId) {
|
|
115
|
+
return {
|
|
116
|
+
providerId,
|
|
117
|
+
configured: Boolean(this.clientId(providerId)),
|
|
118
|
+
flow: "device-code",
|
|
119
|
+
...(this.clientId(providerId) ? {} : { configurationCode: "client_id_missing" }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async start(providerId, signal) {
|
|
123
|
+
const config = PROVIDERS[providerId];
|
|
124
|
+
const clientId = this.clientId(providerId);
|
|
125
|
+
if (!clientId)
|
|
126
|
+
throw new Error(`${providerId} browser login is not configured in this build`);
|
|
127
|
+
for (const [attemptId, attempt] of this.attempts) {
|
|
128
|
+
if (attempt.providerId === providerId)
|
|
129
|
+
this.cancel(attemptId);
|
|
130
|
+
}
|
|
131
|
+
const body = form({
|
|
132
|
+
client_id: clientId,
|
|
133
|
+
scope: config.scopes?.join(" "),
|
|
134
|
+
});
|
|
135
|
+
const response = await this.fetchFn(config.deviceEndpoint, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
Accept: "application/json",
|
|
139
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
140
|
+
},
|
|
141
|
+
body,
|
|
142
|
+
signal: signal
|
|
143
|
+
? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
|
144
|
+
: AbortSignal.timeout(15_000),
|
|
145
|
+
});
|
|
146
|
+
const payload = await safeJson(response, "Device authorization");
|
|
147
|
+
const deviceCode = requiredString(payload.device_code, "device_code", "Device authorization");
|
|
148
|
+
const userCode = requiredString(payload.user_code, "user_code", "Device authorization", 128);
|
|
149
|
+
const verificationUri = verifiedHttpsUrl(payload.verification_uri, "verification_uri", config.verificationHosts);
|
|
150
|
+
const verificationUriComplete = payload.verification_uri_complete
|
|
151
|
+
? verifiedHttpsUrl(payload.verification_uri_complete, "verification_uri_complete", config.verificationHosts)
|
|
152
|
+
: undefined;
|
|
153
|
+
const expiresIn = finiteSeconds(payload.expires_in, 600, 1_800);
|
|
154
|
+
const interval = finiteSeconds(payload.interval, 5, 30);
|
|
155
|
+
const attemptId = this.createId();
|
|
156
|
+
const expiresAtMs = this.now() + expiresIn * 1_000;
|
|
157
|
+
this.attempts.set(attemptId, {
|
|
158
|
+
providerId,
|
|
159
|
+
clientId,
|
|
160
|
+
deviceCode,
|
|
161
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
162
|
+
expiresAtMs,
|
|
163
|
+
intervalMs: interval * 1_000,
|
|
164
|
+
abortController: new AbortController(),
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
attemptId,
|
|
168
|
+
providerId,
|
|
169
|
+
userCode,
|
|
170
|
+
verificationUri,
|
|
171
|
+
verificationUriComplete,
|
|
172
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async complete(attemptId) {
|
|
176
|
+
const attempt = this.attempts.get(attemptId);
|
|
177
|
+
if (!attempt)
|
|
178
|
+
throw new Error("Browser login attempt is unavailable or expired");
|
|
179
|
+
let intervalMs = attempt.intervalMs;
|
|
180
|
+
try {
|
|
181
|
+
while (this.now() < attempt.expiresAtMs) {
|
|
182
|
+
await this.sleep(intervalMs, attempt.abortController.signal);
|
|
183
|
+
const response = await this.fetchFn(attempt.tokenEndpoint, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: {
|
|
186
|
+
Accept: "application/json",
|
|
187
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
188
|
+
},
|
|
189
|
+
body: form({
|
|
190
|
+
client_id: attempt.clientId,
|
|
191
|
+
device_code: attempt.deviceCode,
|
|
192
|
+
grant_type: DEVICE_GRANT,
|
|
193
|
+
}),
|
|
194
|
+
signal: AbortSignal.any([attempt.abortController.signal, AbortSignal.timeout(15_000)]),
|
|
195
|
+
});
|
|
196
|
+
const payload = await safeJson(response, "Device token exchange", true);
|
|
197
|
+
if (typeof payload.access_token === "string" && payload.access_token) {
|
|
198
|
+
const tokenType = typeof payload.token_type === "string" ? payload.token_type.toLowerCase() : "bearer";
|
|
199
|
+
if (tokenType !== "bearer")
|
|
200
|
+
throw new Error("Device token exchange returned non-Bearer token");
|
|
201
|
+
return {
|
|
202
|
+
providerId: attempt.providerId,
|
|
203
|
+
accessToken: requiredString(payload.access_token, "access_token", "Device token exchange"),
|
|
204
|
+
refreshToken: typeof payload.refresh_token === "string" && payload.refresh_token
|
|
205
|
+
? requiredString(payload.refresh_token, "refresh_token", "Device token exchange")
|
|
206
|
+
: undefined,
|
|
207
|
+
expiresIn: typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in)
|
|
208
|
+
? Math.max(0, payload.expires_in)
|
|
209
|
+
: undefined,
|
|
210
|
+
refreshTokenExpiresIn: typeof payload.refresh_token_expires_in === "number" &&
|
|
211
|
+
Number.isFinite(payload.refresh_token_expires_in)
|
|
212
|
+
? Math.max(0, payload.refresh_token_expires_in)
|
|
213
|
+
: undefined,
|
|
214
|
+
clientId: attempt.clientId,
|
|
215
|
+
tokenEndpoint: attempt.tokenEndpoint,
|
|
216
|
+
tokenType: "Bearer",
|
|
217
|
+
scope: typeof payload.scope === "string" ? payload.scope : undefined,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const error = typeof payload.error === "string" ? payload.error : "invalid_response";
|
|
221
|
+
if (error === "authorization_pending")
|
|
222
|
+
continue;
|
|
223
|
+
if (error === "slow_down") {
|
|
224
|
+
intervalMs = Math.min(intervalMs + 5_000, 60_000);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (error === "access_denied")
|
|
228
|
+
throw new Error("Browser authorization was denied");
|
|
229
|
+
if (error === "expired_token" || error === "token_expired") {
|
|
230
|
+
throw new Error("Browser authorization code expired");
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`Browser authorization failed (${error})`);
|
|
233
|
+
}
|
|
234
|
+
throw new Error("Browser authorization code expired");
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
this.attempts.delete(attemptId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
cancel(attemptId) {
|
|
241
|
+
const attempt = this.attempts.get(attemptId);
|
|
242
|
+
if (!attempt)
|
|
243
|
+
return false;
|
|
244
|
+
this.attempts.delete(attemptId);
|
|
245
|
+
attempt.abortController.abort("Browser login cancelled");
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
clientId(providerId) {
|
|
249
|
+
const explicit = this.clientIds[providerId]?.trim();
|
|
250
|
+
if (explicit)
|
|
251
|
+
return explicit;
|
|
252
|
+
const environmentValue = this.environment[PROVIDERS[providerId].clientIdEnvironmentKey]?.trim();
|
|
253
|
+
return environmentValue || undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type LinkServiceOptions } from "./service.js";
|
|
3
|
+
import type { LinkConnectionInput, LinkOperationContext, TokenConnectionInput } from "./types.js";
|
|
4
|
+
export interface LinkHttpOptions extends LinkServiceOptions {
|
|
5
|
+
ownerId: (request: IncomingMessage) => Promise<string | undefined>;
|
|
6
|
+
isAuthorized: (request: IncomingMessage) => Promise<boolean>;
|
|
7
|
+
}
|
|
8
|
+
/** Host authenticates and validates Origin first; writes independently recheck the same owner. */
|
|
9
|
+
export declare function createLinkHttp(options: LinkHttpOptions): {
|
|
10
|
+
service: {
|
|
11
|
+
snapshot: () => import("@cjhyy/code-shell-link").LinkSnapshot;
|
|
12
|
+
persistValidatedConnection: (context: LinkOperationContext, input: LinkConnectionInput, validation: import("@cjhyy/code-shell-core").LocalLinkValidationResult, authentication: import("./service.js").ValidatedLinkAuthentication) => Promise<import("@cjhyy/code-shell-link").MaskedLinkConnection>;
|
|
13
|
+
assertAuthorized: (context: LinkOperationContext) => Promise<void>;
|
|
14
|
+
connectToken: (context: LinkOperationContext, input: TokenConnectionInput) => Promise<import("@cjhyy/code-shell-link").MaskedLinkConnection>;
|
|
15
|
+
connectCli: (context: LinkOperationContext, input: LinkConnectionInput) => Promise<import("@cjhyy/code-shell-link").MaskedLinkConnection>;
|
|
16
|
+
cliStatus: (context: LinkOperationContext, rawProviderId: unknown) => Promise<{
|
|
17
|
+
account?: string | undefined;
|
|
18
|
+
providerId: import("@cjhyy/code-shell-core").CliLinkProviderId;
|
|
19
|
+
command: string;
|
|
20
|
+
installed: boolean;
|
|
21
|
+
authenticated: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
rename: (context: LinkOperationContext, id: string, label: string, expectedRevision: string) => Promise<import("@cjhyy/code-shell-link").MaskedLinkConnection>;
|
|
24
|
+
disconnect: (context: LinkOperationContext, id: string, expectedRevision: string) => Promise<void>;
|
|
25
|
+
startDeviceAuth: (context: LinkOperationContext, input: LinkConnectionInput) => Promise<import("@cjhyy/code-shell-link").LinkAuthorization>;
|
|
26
|
+
authorization: (context: LinkOperationContext, id: string) => Promise<import("@cjhyy/code-shell-link").LinkAuthorization>;
|
|
27
|
+
cancelAuthorization: (context: LinkOperationContext, id: string) => Promise<void>;
|
|
28
|
+
cancelOwner: (ownerId: string) => void;
|
|
29
|
+
close: () => void;
|
|
30
|
+
};
|
|
31
|
+
close: () => void;
|
|
32
|
+
cancelOwner: (ownerId: string) => void;
|
|
33
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
34
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createLinkService, LinkServiceError, publicLinkError, } from "./service.js";
|
|
2
|
+
const ROOT = "/api/v1/links";
|
|
3
|
+
const MAX_BODY_BYTES = 32 * 1024;
|
|
4
|
+
function json(response, status, value) {
|
|
5
|
+
response.writeHead(status, {
|
|
6
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
7
|
+
"Cache-Control": "no-store",
|
|
8
|
+
});
|
|
9
|
+
response.end(JSON.stringify(value));
|
|
10
|
+
}
|
|
11
|
+
async function body(request) {
|
|
12
|
+
if ((request.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase() !==
|
|
13
|
+
"application/json")
|
|
14
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
15
|
+
const declared = Number(request.headers["content-length"] ?? 0);
|
|
16
|
+
if (!Number.isFinite(declared) || declared < 0 || declared > MAX_BODY_BYTES) {
|
|
17
|
+
request.resume();
|
|
18
|
+
throw new LinkServiceError(413, "invalid_request");
|
|
19
|
+
}
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let bytes = 0;
|
|
22
|
+
for await (const part of request.iterator({ destroyOnReturn: false })) {
|
|
23
|
+
const chunk = Buffer.from(part);
|
|
24
|
+
bytes += chunk.length;
|
|
25
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
26
|
+
request.resume();
|
|
27
|
+
throw new LinkServiceError(413, "invalid_request");
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
37
|
+
}
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
39
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function fields(input, allowed) {
|
|
43
|
+
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
|
44
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
45
|
+
}
|
|
46
|
+
/** Host authenticates and validates Origin first; writes independently recheck the same owner. */
|
|
47
|
+
export function createLinkHttp(options) {
|
|
48
|
+
const service = createLinkService(options);
|
|
49
|
+
async function context(request, abandoned) {
|
|
50
|
+
const ownerId = await options.ownerId(request);
|
|
51
|
+
if (!ownerId || !(await options.isAuthorized(request)))
|
|
52
|
+
throw new LinkServiceError(401, "login_required");
|
|
53
|
+
return {
|
|
54
|
+
ownerId,
|
|
55
|
+
authorize: async () => (await options.isAuthorized(request)) &&
|
|
56
|
+
(await options.ownerId(request)) === ownerId &&
|
|
57
|
+
!abandoned(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
service,
|
|
62
|
+
close: service.close,
|
|
63
|
+
cancelOwner: service.cancelOwner,
|
|
64
|
+
async handle(request, response) {
|
|
65
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
66
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(ROOT + "/"))
|
|
67
|
+
return false;
|
|
68
|
+
let abandoned = false;
|
|
69
|
+
const onClose = () => {
|
|
70
|
+
if (!response.writableEnded)
|
|
71
|
+
abandoned = true;
|
|
72
|
+
};
|
|
73
|
+
response.once("close", onClose);
|
|
74
|
+
try {
|
|
75
|
+
const owner = await context(request, () => abandoned || request.aborted);
|
|
76
|
+
const method = request.method;
|
|
77
|
+
let result;
|
|
78
|
+
if (url.pathname === ROOT && method === "GET")
|
|
79
|
+
result = service.snapshot();
|
|
80
|
+
else {
|
|
81
|
+
const cli = /^\/api\/v1\/links\/providers\/([^/]+)\/cli$/.exec(url.pathname);
|
|
82
|
+
const connection = /^\/api\/v1\/links\/connections\/([^/]+)$/.exec(url.pathname);
|
|
83
|
+
const authorization = /^\/api\/v1\/links\/authorizations\/([^/]+)$/.exec(url.pathname);
|
|
84
|
+
if (cli && method === "GET")
|
|
85
|
+
result = await service.cliStatus(owner, decodeURIComponent(cli[1]));
|
|
86
|
+
else if (authorization && method === "GET")
|
|
87
|
+
result = await service.authorization(owner, decodeURIComponent(authorization[1]));
|
|
88
|
+
else if (authorization && method === "DELETE") {
|
|
89
|
+
await service.cancelAuthorization(owner, decodeURIComponent(authorization[1]));
|
|
90
|
+
result = { cancelled: true };
|
|
91
|
+
}
|
|
92
|
+
else if (method === "POST" &&
|
|
93
|
+
[
|
|
94
|
+
ROOT + "/connections/token",
|
|
95
|
+
ROOT + "/connections/cli",
|
|
96
|
+
ROOT + "/authorizations/device",
|
|
97
|
+
].includes(url.pathname)) {
|
|
98
|
+
const input = await body(request);
|
|
99
|
+
const token = url.pathname.endsWith("/token");
|
|
100
|
+
fields(input, [
|
|
101
|
+
"providerId",
|
|
102
|
+
"methodId",
|
|
103
|
+
"label",
|
|
104
|
+
"connectionId",
|
|
105
|
+
"expectedRevision",
|
|
106
|
+
...(token ? ["token"] : []),
|
|
107
|
+
]);
|
|
108
|
+
result = token
|
|
109
|
+
? await service.connectToken(owner, input)
|
|
110
|
+
: url.pathname.endsWith("/cli")
|
|
111
|
+
? await service.connectCli(owner, input)
|
|
112
|
+
: await service.startDeviceAuth(owner, input);
|
|
113
|
+
}
|
|
114
|
+
else if (connection && (method === "PATCH" || method === "DELETE")) {
|
|
115
|
+
const input = await body(request);
|
|
116
|
+
fields(input, method === "PATCH" ? ["label", "expectedRevision"] : ["expectedRevision"]);
|
|
117
|
+
const id = decodeURIComponent(connection[1]);
|
|
118
|
+
if (method === "PATCH")
|
|
119
|
+
result = await service.rename(owner, id, input.label, input.expectedRevision);
|
|
120
|
+
else {
|
|
121
|
+
await service.disconnect(owner, id, input.expectedRevision);
|
|
122
|
+
result = { removed: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
throw new LinkServiceError(404, "not_found");
|
|
127
|
+
}
|
|
128
|
+
await service.assertAuthorized(owner);
|
|
129
|
+
json(response, 200, result);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const failure = error instanceof URIError
|
|
133
|
+
? new LinkServiceError(400, "invalid_request")
|
|
134
|
+
: publicLinkError(error);
|
|
135
|
+
if (!response.destroyed)
|
|
136
|
+
json(response, failure.status, { error: failure.message, code: failure.code });
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
response.off("close", onClose);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CredentialStore, connectCliLink, getCliLinkStatus, validateLocalLinkToken, type LocalLinkValidationResult } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { LinkDeviceOAuthBroker, type LocalBrowserAuthToken } from "./device-oauth.js";
|
|
3
|
+
import type { LinkAuthorization, LinkConnectionInput, LinkErrorCode, LinkOperationContext, LinkSnapshot, MaskedLinkConnection, TokenConnectionInput } from "./types.js";
|
|
4
|
+
export declare class LinkServiceError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly code: LinkErrorCode;
|
|
7
|
+
constructor(status: number, code: LinkErrorCode);
|
|
8
|
+
}
|
|
9
|
+
export declare function publicLinkError(error: unknown): LinkServiceError;
|
|
10
|
+
export interface LinkServiceOptions {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
store?: CredentialStore;
|
|
13
|
+
withMutation?: <T>(write: () => Promise<T>) => Promise<T>;
|
|
14
|
+
onChanged?: () => void | Promise<void>;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
/** Test seams. Production delegates to Core's fixed provider and CLI executors. */
|
|
17
|
+
validateToken?: typeof validateLocalLinkToken;
|
|
18
|
+
cliStatus?: typeof getCliLinkStatus;
|
|
19
|
+
bindCli?: typeof connectCliLink;
|
|
20
|
+
createDeviceBroker?: () => LinkDeviceOAuthBroker;
|
|
21
|
+
}
|
|
22
|
+
/** Trusted host adapter input; deliberately absent from the HTTP API. */
|
|
23
|
+
export type ValidatedLinkAuthentication = {
|
|
24
|
+
source: "manual-token";
|
|
25
|
+
token: string;
|
|
26
|
+
} | {
|
|
27
|
+
source: "browser-oauth";
|
|
28
|
+
token: LocalBrowserAuthToken;
|
|
29
|
+
} | {
|
|
30
|
+
source: "cli-session";
|
|
31
|
+
};
|
|
32
|
+
/** One host/workspace credential domain. Browsers never provide the store or owner identity. */
|
|
33
|
+
export declare function createLinkService(options?: LinkServiceOptions): {
|
|
34
|
+
snapshot: () => LinkSnapshot;
|
|
35
|
+
persistValidatedConnection: (context: LinkOperationContext, input: LinkConnectionInput, validation: LocalLinkValidationResult, authentication: ValidatedLinkAuthentication) => Promise<MaskedLinkConnection>;
|
|
36
|
+
assertAuthorized: (context: LinkOperationContext) => Promise<void>;
|
|
37
|
+
connectToken: (context: LinkOperationContext, input: TokenConnectionInput) => Promise<MaskedLinkConnection>;
|
|
38
|
+
connectCli: (context: LinkOperationContext, input: LinkConnectionInput) => Promise<MaskedLinkConnection>;
|
|
39
|
+
cliStatus: (context: LinkOperationContext, rawProviderId: unknown) => Promise<{
|
|
40
|
+
account?: string | undefined;
|
|
41
|
+
providerId: import("@cjhyy/code-shell-core").CliLinkProviderId;
|
|
42
|
+
command: string;
|
|
43
|
+
installed: boolean;
|
|
44
|
+
authenticated: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
rename: (context: LinkOperationContext, id: string, label: string, expectedRevision: string) => Promise<MaskedLinkConnection>;
|
|
47
|
+
disconnect: (context: LinkOperationContext, id: string, expectedRevision: string) => Promise<void>;
|
|
48
|
+
startDeviceAuth: (context: LinkOperationContext, input: LinkConnectionInput) => Promise<LinkAuthorization>;
|
|
49
|
+
authorization: (context: LinkOperationContext, id: string) => Promise<LinkAuthorization>;
|
|
50
|
+
cancelAuthorization: (context: LinkOperationContext, id: string) => Promise<void>;
|
|
51
|
+
cancelOwner: (ownerId: string) => void;
|
|
52
|
+
close: () => void;
|
|
53
|
+
};
|
|
54
|
+
export type LinkService = ReturnType<typeof createLinkService>;
|