@edge-sky/dsh-oauth-adapter 0.1.1-rc.10
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/LICENSE +21 -0
- package/README.i18n.yaml +4 -0
- package/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +6 -0
- package/lib/authorization-fallback.d.ts +19 -0
- package/lib/authorization-fallback.js +24 -0
- package/lib/client.js +683 -0
- package/lib/index.d.ts +41 -0
- package/lib/index.js +509 -0
- package/lib/invariant.d.ts +9 -0
- package/lib/invariant.js +11 -0
- package/lib/protocol.d.ts +121 -0
- package/lib/protocol.js +83 -0
- package/lib/types/client/OAuthAccountsSection.d.ts +12 -0
- package/lib/types/client/index.d.ts +16 -0
- package/lib/types/client/locales.d.ts +36 -0
- package/lib/types/client/store.d.ts +58 -0
- package/lib/types/client/styles.d.ts +3 -0
- package/lib/types/protocol.d.ts +121 -0
- package/package.json +147 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Web surface for authorization flows owned by the official
|
|
3
|
+
* `dsh-llm-pi-ai` plugin.
|
|
4
|
+
* @module @edge-sky/dsh-oauth-adapter
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage } from 'node:http';
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
+
import type { AuthorizationService } from '@deepseek-ai/dsh-authorization';
|
|
9
|
+
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials';
|
|
10
|
+
import type { WebServer } from '@deepseek-ai/dsh-host-webserver';
|
|
11
|
+
import { type SettingsProvider } from '@deepseek-ai/dsh-settings';
|
|
12
|
+
import z from '@deepseek-ai/schemastery';
|
|
13
|
+
/** Plugin configuration. */
|
|
14
|
+
export interface Config {
|
|
15
|
+
/** Emit secret-free transport lifecycle diagnostics. */
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Runtime-validated plugin configuration. */
|
|
19
|
+
export declare const Config: z<Config>;
|
|
20
|
+
/** Cordis plugin name. */
|
|
21
|
+
export declare const name = "dsh-oauth-adapter";
|
|
22
|
+
/** Official services required by the OAuth account surface. */
|
|
23
|
+
export declare const inject: string[];
|
|
24
|
+
type OAuthContext = Context & {
|
|
25
|
+
authorization: AuthorizationService;
|
|
26
|
+
credentials: CredentialProvider;
|
|
27
|
+
settings: SettingsProvider;
|
|
28
|
+
webServer: WebServer;
|
|
29
|
+
};
|
|
30
|
+
/** Whether a socket address belongs to the local host. */
|
|
31
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
32
|
+
/** Keep provider-supplied navigation targets inside browser-safe URL schemes. */
|
|
33
|
+
export declare function safeNoticeUrl(value: string | undefined): string | undefined;
|
|
34
|
+
/** Validate the browser handshake before `ws` takes ownership of the socket. */
|
|
35
|
+
export declare function acceptsBrowserUpgrade(request: IncomingMessage): boolean;
|
|
36
|
+
/** Format one Host failure for browser diagnostics without forwarding credentials or unbounded data. */
|
|
37
|
+
export declare function formatErrorDetail(error: unknown): string;
|
|
38
|
+
/** Mount the loopback-only OAuth WebSocket over official rc2 services. */
|
|
39
|
+
export declare function apply(ctx: OAuthContext, config: Config): () => Promise<void>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Web surface for authorization flows owned by the official
|
|
3
|
+
* `dsh-llm-pi-ai` plugin.
|
|
4
|
+
* @module @edge-sky/dsh-oauth-adapter
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { inspect } from 'node:util';
|
|
8
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
9
|
+
import z from '@deepseek-ai/schemastery';
|
|
10
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
11
|
+
import { MAX_FRAME_BYTES, OAUTH_SOCKET_PATH, OAUTH_SOCKET_PROTOCOL, PROVIDERS, parseClientCommand, } from './protocol.js';
|
|
12
|
+
/** Runtime-validated plugin configuration. */
|
|
13
|
+
export const Config = z.object({
|
|
14
|
+
debug: z.boolean().default(false),
|
|
15
|
+
});
|
|
16
|
+
/** Cordis plugin name. */
|
|
17
|
+
export const name = 'dsh-oauth-adapter';
|
|
18
|
+
/** Official services required by the OAuth account surface. */
|
|
19
|
+
export const inject = ['authorization', 'credentials', 'settings', 'webServer'];
|
|
20
|
+
const MODEL_SETTINGS_NS = settingsNamespace('llm-pi-ai');
|
|
21
|
+
function providerMeta(providerId) {
|
|
22
|
+
const provider = PROVIDERS.find(candidate => candidate.id === providerId);
|
|
23
|
+
if (provider === undefined)
|
|
24
|
+
throw new Error(`unsupported provider: ${providerId}`);
|
|
25
|
+
return provider;
|
|
26
|
+
}
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function modelRouteEnabled(settings, provider) {
|
|
31
|
+
const section = settings.get(MODEL_SETTINGS_NS);
|
|
32
|
+
if (!isRecord(section) || !isRecord(section.providers))
|
|
33
|
+
return false;
|
|
34
|
+
return Object.hasOwn(section.providers, provider);
|
|
35
|
+
}
|
|
36
|
+
function pluginOwnsModelRoute(settings, provider) {
|
|
37
|
+
const descriptor = settings.describe().find(candidate => candidate.ns === MODEL_SETTINGS_NS);
|
|
38
|
+
if (!isRecord(descriptor?.user) || !isRecord(descriptor.user.providers))
|
|
39
|
+
return false;
|
|
40
|
+
const profile = descriptor.user.providers[provider.id];
|
|
41
|
+
return isRecord(profile)
|
|
42
|
+
&& Object.keys(profile).length === 1
|
|
43
|
+
&& profile.displayName === provider.modelGroupLabel;
|
|
44
|
+
}
|
|
45
|
+
const GITHUB_DOMAIN_PROMPT = 'GitHub Enterprise URL/domain (blank for github.com)';
|
|
46
|
+
function promptView(prompt, provider) {
|
|
47
|
+
if (prompt.kind === 'select') {
|
|
48
|
+
return { kind: 'select', message: prompt.message, options: prompt.options.map(option => ({ ...option })) };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
kind: prompt.kind,
|
|
52
|
+
message: prompt.message,
|
|
53
|
+
...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
|
|
54
|
+
...provider === 'github-copilot' && prompt.kind === 'text' && prompt.message === GITHUB_DOMAIN_PROMPT
|
|
55
|
+
? { allowEmpty: true }
|
|
56
|
+
: {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Whether a socket address belongs to the local host. */
|
|
60
|
+
export function isLoopbackAddress(address) {
|
|
61
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
62
|
+
}
|
|
63
|
+
/** Keep provider-supplied navigation targets inside browser-safe URL schemes. */
|
|
64
|
+
export function safeNoticeUrl(value) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(value);
|
|
69
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Validate the browser handshake before `ws` takes ownership of the socket. */
|
|
76
|
+
export function acceptsBrowserUpgrade(request) {
|
|
77
|
+
const host = request.headers.host;
|
|
78
|
+
const origin = request.headers.origin;
|
|
79
|
+
if (host === undefined || origin === undefined || !isLoopbackAddress(request.socket.remoteAddress))
|
|
80
|
+
return false;
|
|
81
|
+
if (request.headers['sec-websocket-protocol'] !== OAUTH_SOCKET_PROTOCOL)
|
|
82
|
+
return false;
|
|
83
|
+
let hostUrl;
|
|
84
|
+
let originUrl;
|
|
85
|
+
try {
|
|
86
|
+
hostUrl = new URL(`http://${host}`);
|
|
87
|
+
originUrl = new URL(origin);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (originUrl.protocol !== 'http:' || originUrl.host !== hostUrl.host)
|
|
93
|
+
return false;
|
|
94
|
+
return hostUrl.hostname === '127.0.0.1' || hostUrl.hostname === 'localhost' || hostUrl.hostname === '[::1]';
|
|
95
|
+
}
|
|
96
|
+
function rejectUpgrade(socket) {
|
|
97
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
98
|
+
socket.destroy();
|
|
99
|
+
}
|
|
100
|
+
function rawLength(raw) {
|
|
101
|
+
if (Array.isArray(raw))
|
|
102
|
+
return raw.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
103
|
+
return raw.byteLength;
|
|
104
|
+
}
|
|
105
|
+
const MAX_ERROR_DETAIL_LENGTH = 12 * 1024;
|
|
106
|
+
const MAX_ERROR_CAUSE_DEPTH = 6;
|
|
107
|
+
function redactErrorDetail(value) {
|
|
108
|
+
return value
|
|
109
|
+
.replace(/(\bAuthorization\b\s*["']?\s*[:=]\s*["']?)(?:Bearer\s+)?([^"'\s,}&]+)/giu, '$1[REDACTED]')
|
|
110
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, 'Bearer [REDACTED]')
|
|
111
|
+
.replace(/(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|api[_-]?key)\b\s*["']?\s*[:=]\s*["']?)([^"'\s,}&]+)/giu, '$1[REDACTED]')
|
|
112
|
+
.replace(/\b(?:github_pat_|gh[opusr]_|sk-)[A-Za-z0-9_-]{12,}\b/gu, '[REDACTED]');
|
|
113
|
+
}
|
|
114
|
+
function errorProperties(value) {
|
|
115
|
+
const record = value;
|
|
116
|
+
const properties = ['code', 'status', 'statusCode']
|
|
117
|
+
.flatMap((key) => {
|
|
118
|
+
const entry = record[key];
|
|
119
|
+
return typeof entry === 'string' || typeof entry === 'number' ? [`${key}: ${String(entry)}`] : [];
|
|
120
|
+
});
|
|
121
|
+
return properties.length === 0 ? undefined : properties.join(', ');
|
|
122
|
+
}
|
|
123
|
+
/** Format one Host failure for browser diagnostics without forwarding credentials or unbounded data. */
|
|
124
|
+
export function formatErrorDetail(error) {
|
|
125
|
+
const sections = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
let current = error;
|
|
128
|
+
for (let depth = 0; current !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) {
|
|
129
|
+
if (typeof current === 'object' && current !== null) {
|
|
130
|
+
if (seen.has(current)) {
|
|
131
|
+
sections.push('Caused by: [circular error cause]');
|
|
132
|
+
current = undefined;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
seen.add(current);
|
|
136
|
+
}
|
|
137
|
+
const prefix = depth === 0 ? '' : 'Caused by: ';
|
|
138
|
+
if (current instanceof Error) {
|
|
139
|
+
const properties = errorProperties(current);
|
|
140
|
+
const rendered = current.stack ?? `${current.name}: ${current.message}`;
|
|
141
|
+
sections.push(`${prefix}${rendered}${properties === undefined ? '' : `\n${properties}`}`);
|
|
142
|
+
current = current.cause;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
sections.push(prefix + (typeof current === 'string'
|
|
146
|
+
? current
|
|
147
|
+
: inspect(current, {
|
|
148
|
+
breakLength: 120,
|
|
149
|
+
compact: false,
|
|
150
|
+
customInspect: false,
|
|
151
|
+
depth: 4,
|
|
152
|
+
maxArrayLength: 40,
|
|
153
|
+
maxStringLength: 4096,
|
|
154
|
+
sorted: true,
|
|
155
|
+
})));
|
|
156
|
+
current = undefined;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
if (current !== undefined)
|
|
160
|
+
sections.push('Caused by: [additional causes omitted]');
|
|
161
|
+
const redacted = redactErrorDetail(sections.join('\n'));
|
|
162
|
+
if (redacted.length <= MAX_ERROR_DETAIL_LENGTH)
|
|
163
|
+
return redacted;
|
|
164
|
+
return `${redacted.slice(0, MAX_ERROR_DETAIL_LENGTH)}\n[diagnostic truncated]`;
|
|
165
|
+
}
|
|
166
|
+
/** One ownership-scoped browser authorization connection. */
|
|
167
|
+
class OAuthConnection {
|
|
168
|
+
ctx;
|
|
169
|
+
socket;
|
|
170
|
+
debug;
|
|
171
|
+
changed;
|
|
172
|
+
active;
|
|
173
|
+
queue = Promise.resolve();
|
|
174
|
+
closed = false;
|
|
175
|
+
constructor(ctx, socket, debug, changed) {
|
|
176
|
+
this.ctx = ctx;
|
|
177
|
+
this.socket = socket;
|
|
178
|
+
this.debug = debug;
|
|
179
|
+
this.changed = changed;
|
|
180
|
+
socket.on('message', (raw, isBinary) => {
|
|
181
|
+
if (isBinary || rawLength(raw) > MAX_FRAME_BYTES) {
|
|
182
|
+
this.sendError('invalid-message', 'message must be bounded UTF-8 JSON');
|
|
183
|
+
socket.close(1009, 'invalid message');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.queue = this.queue.then(() => this.receive(raw.toString())).catch((error) => {
|
|
187
|
+
this.ctx.logger.warn('dsh-oauth-adapter: command handling failed');
|
|
188
|
+
this.ctx.logger.warn(error);
|
|
189
|
+
this.sendError('operation-failed', 'the OAuth operation failed', undefined, undefined, formatErrorDetail(error));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
socket.on('close', () => {
|
|
193
|
+
this.closed = true;
|
|
194
|
+
this.withdrawActive('browser connection closed');
|
|
195
|
+
this.debug('connection=closed');
|
|
196
|
+
});
|
|
197
|
+
socket.on('error', (error) => {
|
|
198
|
+
this.ctx.logger.debug('dsh-oauth-adapter: browser socket failed: %s', error.message);
|
|
199
|
+
});
|
|
200
|
+
this.debug('connection=opened');
|
|
201
|
+
void this.sendSnapshot();
|
|
202
|
+
}
|
|
203
|
+
refresh() {
|
|
204
|
+
void this.sendSnapshot();
|
|
205
|
+
}
|
|
206
|
+
dispose() {
|
|
207
|
+
this.closed = true;
|
|
208
|
+
this.withdrawActive('OAuth surface stopped');
|
|
209
|
+
this.socket.terminate();
|
|
210
|
+
}
|
|
211
|
+
send(message) {
|
|
212
|
+
if (this.socket.readyState === WebSocket.OPEN)
|
|
213
|
+
this.socket.send(JSON.stringify(message));
|
|
214
|
+
}
|
|
215
|
+
sendError(code, message, requestId, attemptId, detail) {
|
|
216
|
+
this.send({
|
|
217
|
+
type: 'error', code, message,
|
|
218
|
+
...requestId === undefined ? {} : { requestId },
|
|
219
|
+
...attemptId === undefined ? {} : { attemptId },
|
|
220
|
+
...detail === undefined ? {} : { detail },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async receive(raw) {
|
|
224
|
+
const parsed = parseClientCommand(raw);
|
|
225
|
+
if (!parsed.ok) {
|
|
226
|
+
this.sendError(parsed.message === 'provider is unsupported' ? 'unsupported-provider' : 'invalid-message', parsed.message, parsed.requestId);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await this.handle(parsed.value);
|
|
230
|
+
}
|
|
231
|
+
async handle(command) {
|
|
232
|
+
switch (command.type) {
|
|
233
|
+
case 'refresh':
|
|
234
|
+
await this.sendSnapshot(command.requestId);
|
|
235
|
+
return;
|
|
236
|
+
case 'begin':
|
|
237
|
+
this.begin(command);
|
|
238
|
+
return;
|
|
239
|
+
case 'respond':
|
|
240
|
+
this.respond(command);
|
|
241
|
+
return;
|
|
242
|
+
case 'cancel':
|
|
243
|
+
this.cancel(command);
|
|
244
|
+
return;
|
|
245
|
+
case 'forget':
|
|
246
|
+
await this.forget(command);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async account(provider) {
|
|
250
|
+
const entry = this.ctx.authorization.describe(provider.key);
|
|
251
|
+
const credential = await this.ctx.credentials.describeRecord(provider.key);
|
|
252
|
+
let modelsEnabled = modelRouteEnabled(this.ctx.settings, provider.id);
|
|
253
|
+
if (credential.configured && !modelsEnabled && this.ctx.settings.writable) {
|
|
254
|
+
try {
|
|
255
|
+
modelsEnabled = await this.enableModelRoute(provider);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.ctx.logger.warn('dsh-oauth-adapter: model route activation failed for %s', provider.id);
|
|
259
|
+
this.ctx.logger.warn(error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
provider: provider.id,
|
|
264
|
+
label: entry?.label ?? provider.fallbackLabel,
|
|
265
|
+
available: entry?.methods.some(method => method.id === 'oauth') === true,
|
|
266
|
+
configured: credential.configured,
|
|
267
|
+
modelsEnabled,
|
|
268
|
+
writable: credential.writable,
|
|
269
|
+
inFlight: entry?.inFlight ?? false,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async enableModelRoute(provider) {
|
|
273
|
+
if (modelRouteEnabled(this.ctx.settings, provider.id))
|
|
274
|
+
return true;
|
|
275
|
+
if (!this.ctx.settings.writable)
|
|
276
|
+
return false;
|
|
277
|
+
await this.ctx.settings.mutate(MODEL_SETTINGS_NS, [{
|
|
278
|
+
op: 'set',
|
|
279
|
+
path: ['providers', provider.id],
|
|
280
|
+
value: { displayName: provider.modelGroupLabel },
|
|
281
|
+
}]);
|
|
282
|
+
return modelRouteEnabled(this.ctx.settings, provider.id);
|
|
283
|
+
}
|
|
284
|
+
async sendSnapshot(requestId) {
|
|
285
|
+
try {
|
|
286
|
+
const accounts = await Promise.all(PROVIDERS.map(provider => this.account(provider)));
|
|
287
|
+
this.send({ type: 'snapshot', accounts, ...requestId === undefined ? {} : { requestId } });
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
this.ctx.logger.warn('dsh-oauth-adapter: credential status read failed');
|
|
291
|
+
this.ctx.logger.warn(error);
|
|
292
|
+
this.sendError('operation-failed', 'account status is unavailable', requestId, undefined, formatErrorDetail(error));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
begin(command) {
|
|
296
|
+
if (this.active !== undefined) {
|
|
297
|
+
this.sendError('busy', 'this browser already owns an OAuth attempt', command.requestId, this.active.id);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const provider = providerMeta(command.provider);
|
|
301
|
+
const entry = this.ctx.authorization.describe(provider.key);
|
|
302
|
+
if (entry === undefined || !entry.methods.some(method => method.id === 'oauth')) {
|
|
303
|
+
this.sendError('authorization-unavailable', 'this provider has no OAuth flow', command.requestId);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (entry.inFlight) {
|
|
307
|
+
this.sendError('busy', 'an OAuth attempt for this provider is already running', command.requestId);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const attempt = {
|
|
311
|
+
id: randomUUID(), provider: command.provider, key: provider.key,
|
|
312
|
+
controller: new AbortController(), prompts: new Map(),
|
|
313
|
+
};
|
|
314
|
+
this.active = attempt;
|
|
315
|
+
this.changed();
|
|
316
|
+
this.send({ type: 'started', requestId: command.requestId, attemptId: attempt.id, provider: attempt.provider });
|
|
317
|
+
this.debug(`provider=${attempt.provider} stage=started`);
|
|
318
|
+
void this.ctx.authorization.begin({
|
|
319
|
+
key: attempt.key,
|
|
320
|
+
method: 'oauth',
|
|
321
|
+
signal: attempt.controller.signal,
|
|
322
|
+
interaction: {
|
|
323
|
+
notify: notice => {
|
|
324
|
+
const url = safeNoticeUrl(notice.url);
|
|
325
|
+
this.send({
|
|
326
|
+
type: 'notice', attemptId: attempt.id, message: notice.message,
|
|
327
|
+
...url === undefined ? {} : { url },
|
|
328
|
+
...notice.code === undefined ? {} : { code: notice.code },
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
prompt: prompt => this.openPrompt(attempt, prompt),
|
|
332
|
+
},
|
|
333
|
+
}).then(async (outcome) => {
|
|
334
|
+
if (outcome.status === 'authorized') {
|
|
335
|
+
try {
|
|
336
|
+
const enabled = await this.enableModelRoute(provider);
|
|
337
|
+
if (!enabled) {
|
|
338
|
+
this.sendError('model-route-unavailable', 'sign-in completed, but the OAuth model route could not be enabled', undefined, attempt.id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
this.ctx.logger.warn('dsh-oauth-adapter: model route activation failed for %s', provider.id);
|
|
343
|
+
this.ctx.logger.warn(error);
|
|
344
|
+
this.sendError('model-route-unavailable', 'sign-in completed, but the OAuth model route could not be enabled', undefined, attempt.id, formatErrorDetail(error));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
this.finish(attempt, outcome.status);
|
|
348
|
+
}, (error) => {
|
|
349
|
+
this.ctx.logger.warn('dsh-oauth-adapter: provider sign-in failed for %s', attempt.provider);
|
|
350
|
+
this.ctx.logger.warn(error);
|
|
351
|
+
this.sendError('sign-in-failed', 'sign-in failed', undefined, attempt.id, formatErrorDetail(error));
|
|
352
|
+
this.finish(attempt, 'failed');
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
openPrompt(attempt, prompt) {
|
|
356
|
+
if (this.active !== attempt || this.closed)
|
|
357
|
+
return Promise.reject(new Error('OAuth connection is closed'));
|
|
358
|
+
const promptId = randomUUID();
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
let settled = false;
|
|
361
|
+
const finish = (callback) => {
|
|
362
|
+
if (settled)
|
|
363
|
+
return;
|
|
364
|
+
settled = true;
|
|
365
|
+
prompt.signal?.removeEventListener('abort', withdraw);
|
|
366
|
+
attempt.prompts.delete(promptId);
|
|
367
|
+
callback();
|
|
368
|
+
};
|
|
369
|
+
const withdraw = () => {
|
|
370
|
+
finish(() => {
|
|
371
|
+
this.send({ type: 'prompt-withdrawn', attemptId: attempt.id, promptId });
|
|
372
|
+
reject(new Error('authorization prompt was withdrawn'));
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
attempt.prompts.set(promptId, {
|
|
376
|
+
resolve: value => { finish(() => { resolve(value); }); },
|
|
377
|
+
reject: reason => { finish(() => { reject(reason); }); },
|
|
378
|
+
});
|
|
379
|
+
prompt.signal?.addEventListener('abort', withdraw, { once: true });
|
|
380
|
+
if (prompt.signal?.aborted === true) {
|
|
381
|
+
withdraw();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.send({
|
|
385
|
+
type: 'prompt', attemptId: attempt.id, promptId,
|
|
386
|
+
prompt: promptView(prompt, attempt.provider),
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
respond(command) {
|
|
391
|
+
const active = this.active;
|
|
392
|
+
if (active?.id !== command.attemptId) {
|
|
393
|
+
this.sendError('invalid-attempt', 'attempt does not belong to this browser', command.requestId);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const prompt = active.prompts.get(command.promptId);
|
|
397
|
+
if (prompt === undefined) {
|
|
398
|
+
this.sendError('invalid-prompt', 'prompt is expired or unknown', command.requestId, active.id);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
prompt.resolve(command.value);
|
|
402
|
+
this.send({ type: 'ack', requestId: command.requestId });
|
|
403
|
+
}
|
|
404
|
+
cancel(command) {
|
|
405
|
+
if (this.active?.id !== command.attemptId) {
|
|
406
|
+
this.sendError('invalid-attempt', 'attempt does not belong to this browser', command.requestId);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.withdrawActive('authorization cancelled by browser');
|
|
410
|
+
this.send({ type: 'ack', requestId: command.requestId });
|
|
411
|
+
}
|
|
412
|
+
async forget(command) {
|
|
413
|
+
const provider = providerMeta(command.provider);
|
|
414
|
+
if (this.ctx.authorization.describe(provider.key)?.inFlight === true) {
|
|
415
|
+
this.sendError('busy', 'cancel the active attempt before signing out', command.requestId);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const current = await this.ctx.credentials.describeRecord(provider.key);
|
|
419
|
+
if (!current.writable) {
|
|
420
|
+
this.sendError('credential-read-only', 'this credential store is read-only', command.requestId);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
await this.ctx.credentials.deleteRecord(provider.key);
|
|
425
|
+
if (pluginOwnsModelRoute(this.ctx.settings, provider)) {
|
|
426
|
+
await this.ctx.settings.mutate(MODEL_SETTINGS_NS, [{
|
|
427
|
+
op: 'unset', path: ['providers', provider.id],
|
|
428
|
+
}]);
|
|
429
|
+
}
|
|
430
|
+
this.send({ type: 'ack', requestId: command.requestId });
|
|
431
|
+
await this.sendSnapshot();
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
this.ctx.logger.warn('dsh-oauth-adapter: sign-out failed for %s', provider.id);
|
|
435
|
+
this.ctx.logger.warn(error);
|
|
436
|
+
this.sendError('operation-failed', 'sign-out failed', command.requestId, undefined, formatErrorDetail(error));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finish(attempt, status) {
|
|
440
|
+
if (this.active !== attempt)
|
|
441
|
+
return;
|
|
442
|
+
this.rejectPrompts(attempt, new Error(`authorization ${status}`));
|
|
443
|
+
this.active = undefined;
|
|
444
|
+
this.changed();
|
|
445
|
+
this.send({ type: 'settled', attemptId: attempt.id, status });
|
|
446
|
+
this.debug(`provider=${attempt.provider} stage=settled status=${status}`);
|
|
447
|
+
void this.sendSnapshot();
|
|
448
|
+
}
|
|
449
|
+
withdrawActive(reason) {
|
|
450
|
+
const active = this.active;
|
|
451
|
+
if (active === undefined)
|
|
452
|
+
return;
|
|
453
|
+
active.controller.abort(new Error(reason));
|
|
454
|
+
this.rejectPrompts(active, new Error(reason));
|
|
455
|
+
}
|
|
456
|
+
rejectPrompts(attempt, reason) {
|
|
457
|
+
for (const prompt of [...attempt.prompts.values()])
|
|
458
|
+
prompt.reject(reason);
|
|
459
|
+
attempt.prompts.clear();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Mount the loopback-only OAuth WebSocket over official rc2 services. */
|
|
463
|
+
export function apply(ctx, config) {
|
|
464
|
+
const debug = config.debug === true
|
|
465
|
+
? (message) => { ctx.logger.debug('dsh-oauth-adapter: %s', message); }
|
|
466
|
+
: () => { };
|
|
467
|
+
const connections = new Set();
|
|
468
|
+
const server = new WebSocketServer({
|
|
469
|
+
noServer: true,
|
|
470
|
+
maxPayload: MAX_FRAME_BYTES,
|
|
471
|
+
handleProtocols: protocols => protocols.has(OAUTH_SOCKET_PROTOCOL) ? OAUTH_SOCKET_PROTOCOL : false,
|
|
472
|
+
});
|
|
473
|
+
server.on('connection', (socket) => {
|
|
474
|
+
const connection = new OAuthConnection(ctx, socket, debug, refresh);
|
|
475
|
+
connections.add(connection);
|
|
476
|
+
socket.once('close', () => { connections.delete(connection); });
|
|
477
|
+
});
|
|
478
|
+
server.on('error', (error) => {
|
|
479
|
+
ctx.logger.warn('dsh-oauth-adapter: WebSocket server failed');
|
|
480
|
+
ctx.logger.warn(error);
|
|
481
|
+
});
|
|
482
|
+
const unregister = ctx.webServer.registerUpgrade({
|
|
483
|
+
path: OAUTH_SOCKET_PATH,
|
|
484
|
+
handler(request, socket, head) {
|
|
485
|
+
if (!acceptsBrowserUpgrade(request)) {
|
|
486
|
+
rejectUpgrade(socket);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
server.handleUpgrade(request, socket, head, (webSocket) => {
|
|
490
|
+
server.emit('connection', webSocket, request);
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const refresh = () => { for (const connection of connections)
|
|
495
|
+
connection.refresh(); };
|
|
496
|
+
const credentialDisposer = ctx.on('credentials/record-updated', (key) => {
|
|
497
|
+
if (PROVIDERS.some(provider => provider.key === key))
|
|
498
|
+
refresh();
|
|
499
|
+
});
|
|
500
|
+
return async () => {
|
|
501
|
+
credentialDisposer();
|
|
502
|
+
unregister();
|
|
503
|
+
for (const connection of connections)
|
|
504
|
+
connection.dispose();
|
|
505
|
+
connections.clear();
|
|
506
|
+
await new Promise((resolve) => { server.close(() => { resolve(); }); });
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Package-owned invariant companion for `@edge-sky/dsh-oauth-adapter`. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "dsh-oauth-adapter-invariant";
|
|
5
|
+
/** Service required before package ownership is registered. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/** Register this package's invariant companion. */
|
|
8
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
9
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Package-owned invariant companion for `@edge-sky/dsh-oauth-adapter`. */
|
|
2
|
+
const PACKAGE_NAME = '@edge-sky/dsh-oauth-adapter';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export const name = 'dsh-oauth-adapter-invariant';
|
|
5
|
+
/** Service required before package ownership is registered. */
|
|
6
|
+
export const inject = ['invariants'];
|
|
7
|
+
/** No runtime invariant: the Host owns and tears down every socket and attempt directly. */
|
|
8
|
+
const install = () => { };
|
|
9
|
+
/** Register this package's invariant companion. */
|
|
10
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
11
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Shared browser/Host wire vocabulary for the OAuth account surface. */
|
|
2
|
+
export declare const OAUTH_SOCKET_PATH = "/_edge-sky/dsh-oauth";
|
|
3
|
+
export declare const OAUTH_SOCKET_PROTOCOL = "dsh-oauth-v1";
|
|
4
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
5
|
+
export declare const MAX_ANSWER_LENGTH: number;
|
|
6
|
+
export declare const PROVIDERS: readonly [{
|
|
7
|
+
readonly id: "openai-codex";
|
|
8
|
+
readonly key: "llm-pi-ai/openai-codex";
|
|
9
|
+
readonly fallbackLabel: "OpenAI Codex";
|
|
10
|
+
readonly modelGroupLabel: "OpenAI Codex (OAuth)";
|
|
11
|
+
}, {
|
|
12
|
+
readonly id: "github-copilot";
|
|
13
|
+
readonly key: "llm-pi-ai/github-copilot";
|
|
14
|
+
readonly fallbackLabel: "GitHub Copilot";
|
|
15
|
+
readonly modelGroupLabel: "GitHub Copilot (OAuth)";
|
|
16
|
+
}];
|
|
17
|
+
export type ProviderId = (typeof PROVIDERS)[number]['id'];
|
|
18
|
+
export interface AccountView {
|
|
19
|
+
provider: ProviderId;
|
|
20
|
+
label: string;
|
|
21
|
+
available: boolean;
|
|
22
|
+
configured: boolean;
|
|
23
|
+
/** Whether llm-pi-ai currently exposes this OAuth provider as a model route. */
|
|
24
|
+
modelsEnabled: boolean;
|
|
25
|
+
writable: boolean;
|
|
26
|
+
inFlight: boolean;
|
|
27
|
+
}
|
|
28
|
+
export type PromptView = {
|
|
29
|
+
kind: 'text';
|
|
30
|
+
message: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
/** Whether an empty response has provider-defined meaning. */
|
|
33
|
+
allowEmpty?: boolean;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'secret';
|
|
36
|
+
message: string;
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'select';
|
|
40
|
+
message: string;
|
|
41
|
+
options: readonly {
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
export type ClientCommand = {
|
|
48
|
+
type: 'refresh';
|
|
49
|
+
requestId: string;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'begin';
|
|
52
|
+
requestId: string;
|
|
53
|
+
provider: ProviderId;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'respond';
|
|
56
|
+
requestId: string;
|
|
57
|
+
attemptId: string;
|
|
58
|
+
promptId: string;
|
|
59
|
+
value: string;
|
|
60
|
+
} | {
|
|
61
|
+
type: 'cancel';
|
|
62
|
+
requestId: string;
|
|
63
|
+
attemptId: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'forget';
|
|
66
|
+
requestId: string;
|
|
67
|
+
provider: ProviderId;
|
|
68
|
+
};
|
|
69
|
+
export type OAuthErrorCode = 'invalid-message' | 'unsupported-provider' | 'authorization-unavailable' | 'busy' | 'invalid-attempt' | 'invalid-prompt' | 'credential-read-only' | 'model-route-unavailable' | 'sign-in-failed' | 'operation-failed';
|
|
70
|
+
export type ServerMessage = {
|
|
71
|
+
type: 'snapshot';
|
|
72
|
+
requestId?: string;
|
|
73
|
+
accounts: readonly AccountView[];
|
|
74
|
+
} | {
|
|
75
|
+
type: 'started';
|
|
76
|
+
requestId: string;
|
|
77
|
+
attemptId: string;
|
|
78
|
+
provider: ProviderId;
|
|
79
|
+
} | {
|
|
80
|
+
type: 'notice';
|
|
81
|
+
attemptId: string;
|
|
82
|
+
message: string;
|
|
83
|
+
url?: string;
|
|
84
|
+
code?: string;
|
|
85
|
+
} | {
|
|
86
|
+
type: 'prompt';
|
|
87
|
+
attemptId: string;
|
|
88
|
+
promptId: string;
|
|
89
|
+
prompt: PromptView;
|
|
90
|
+
} | {
|
|
91
|
+
type: 'prompt-withdrawn';
|
|
92
|
+
attemptId: string;
|
|
93
|
+
promptId: string;
|
|
94
|
+
} | {
|
|
95
|
+
type: 'settled';
|
|
96
|
+
attemptId: string;
|
|
97
|
+
status: 'authorized' | 'cancelled' | 'failed';
|
|
98
|
+
} | {
|
|
99
|
+
type: 'ack';
|
|
100
|
+
requestId: string;
|
|
101
|
+
} | {
|
|
102
|
+
type: 'error';
|
|
103
|
+
requestId?: string;
|
|
104
|
+
attemptId?: string;
|
|
105
|
+
code: OAuthErrorCode;
|
|
106
|
+
message: string;
|
|
107
|
+
/** Bounded, credential-redacted Host diagnostic for optional UI expansion. */
|
|
108
|
+
detail?: string;
|
|
109
|
+
};
|
|
110
|
+
export type CommandParseResult = {
|
|
111
|
+
ok: true;
|
|
112
|
+
value: ClientCommand;
|
|
113
|
+
} | {
|
|
114
|
+
ok: false;
|
|
115
|
+
requestId?: string;
|
|
116
|
+
message: string;
|
|
117
|
+
};
|
|
118
|
+
export declare function isProviderId(value: unknown): value is ProviderId;
|
|
119
|
+
/** Parse one untrusted browser frame without retaining its raw text. */
|
|
120
|
+
export declare function parseClientCommand(raw: string): CommandParseResult;
|
|
121
|
+
//# sourceMappingURL=protocol.d.ts.map
|