@cjhyy/code-shell-server 0.9.44 → 0.9.47

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.
Files changed (94) hide show
  1. package/dist/attachment-service.d.ts +2 -0
  2. package/dist/attachment-service.js +40 -27
  3. package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
  4. package/dist/bin/code-shell-link-cleanup.js +3 -0
  5. package/dist/desktop-web/http-api.d.ts +3 -0
  6. package/dist/desktop-web/http-api.js +16 -1
  7. package/dist/hub/configuration.d.ts +6 -3
  8. package/dist/hub/configuration.js +19 -6
  9. package/dist/hub/mcp-probe.d.ts +4 -24
  10. package/dist/hub/mcp-probe.js +18 -389
  11. package/dist/hub/model-probe.d.ts +2 -20
  12. package/dist/hub/model-probe.js +2 -193
  13. package/dist/hub/run-replay.d.ts +7 -1
  14. package/dist/hub/run-replay.js +10 -2
  15. package/dist/hub/session-management.js +18 -53
  16. package/dist/hub/skills-management.js +6 -8
  17. package/dist/hub/uploads.d.ts +10 -0
  18. package/dist/hub/uploads.js +107 -15
  19. package/dist/index.desktop-web.d.ts +1 -0
  20. package/dist/index.desktop-web.js +1 -0
  21. package/dist/index.links.d.ts +3 -0
  22. package/dist/index.links.js +2 -0
  23. package/dist/index.panels.d.ts +1 -0
  24. package/dist/index.panels.js +1 -0
  25. package/dist/index.serve.d.ts +1 -0
  26. package/dist/index.serve.js +1 -0
  27. package/dist/index.storage.d.ts +1 -0
  28. package/dist/index.storage.js +1 -0
  29. package/dist/links/catalog.js +2 -0
  30. package/dist/links/diagnostics.d.ts +60 -0
  31. package/dist/links/diagnostics.js +184 -0
  32. package/dist/links/http.d.ts +10 -8
  33. package/dist/links/http.js +144 -125
  34. package/dist/links/legacy-cleanup-cli.d.ts +3 -0
  35. package/dist/links/legacy-cleanup-cli.js +64 -0
  36. package/dist/links/providers/defaults.d.ts +5 -0
  37. package/dist/links/providers/defaults.js +782 -0
  38. package/dist/links/providers/index.d.ts +7 -0
  39. package/dist/links/providers/index.js +22 -0
  40. package/dist/links/remote-catalog.d.ts +10 -3
  41. package/dist/links/remote-catalog.js +76 -29
  42. package/dist/links/runtime/operation.d.ts +9 -0
  43. package/dist/links/runtime/operation.js +16 -0
  44. package/dist/links/service.d.ts +18 -2
  45. package/dist/links/service.js +593 -62
  46. package/dist/links/types.d.ts +21 -1
  47. package/dist/mobile-remote/codex-parse.d.ts +3 -1
  48. package/dist/mobile-remote/codex-parse.js +45 -11
  49. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  50. package/dist/mobile-remote/codex-room-agent.js +30 -32
  51. package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
  52. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  53. package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
  54. package/dist/mobile-remote/mobile-history.d.ts +10 -1
  55. package/dist/mobile-remote/mobile-history.js +24 -16
  56. package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
  57. package/dist/mobile-remote/remote-host-manager.js +50 -13
  58. package/dist/mobile-remote/resident-agent.js +38 -22
  59. package/dist/mobile-remote/room-manager.d.ts +23 -13
  60. package/dist/mobile-remote/room-manager.js +106 -22
  61. package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
  62. package/dist/mobile-remote/room-model-tracing.js +196 -0
  63. package/dist/mobile-remote/room-process-stop.d.ts +3 -0
  64. package/dist/mobile-remote/room-process-stop.js +28 -0
  65. package/dist/model-trace-history.d.ts +29 -0
  66. package/dist/model-trace-history.js +198 -0
  67. package/dist/model-trace-http.d.ts +14 -0
  68. package/dist/model-trace-http.js +105 -0
  69. package/dist/panels/agent-task-host.js +1 -0
  70. package/dist/panels/agent-task-service.d.ts +2 -1
  71. package/dist/panels/agent-task-service.js +10 -3
  72. package/dist/panels/directory-bookmarks.js +62 -29
  73. package/dist/panels/execution-gate.d.ts +19 -1
  74. package/dist/panels/execution-gate.js +33 -5
  75. package/dist/panels/http.d.ts +1 -0
  76. package/dist/panels/hub-automations.js +1 -0
  77. package/dist/panels/hub-binding.js +5 -5
  78. package/dist/panels/model-trace-channel.d.ts +21 -0
  79. package/dist/panels/model-trace-channel.js +110 -0
  80. package/dist/panels/model-traces.d.ts +39 -0
  81. package/dist/panels/model-traces.js +222 -0
  82. package/dist/panels/process-service.d.ts +12 -2
  83. package/dist/panels/process-service.js +49 -6
  84. package/dist/panels/resources/uploads.js +15 -6
  85. package/dist/panels/runtime.d.ts +1 -0
  86. package/dist/panels/runtime.js +66 -8
  87. package/dist/panels/task-cookies.js +8 -16
  88. package/dist/panels/tool-jobs.d.ts +2 -0
  89. package/dist/panels/tool-jobs.js +28 -3
  90. package/dist/serve/cli.js +8 -1
  91. package/dist/serve/headless-server.js +68 -14
  92. package/dist/sessions-service.js +17 -27
  93. package/dist/worker-bridge-core.js +11 -0
  94. package/package.json +8 -7
@@ -1,7 +1,9 @@
1
+ import { linkConnectionMetadata } from "@cjhyy/code-shell-link";
1
2
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
3
  import { CredentialStore, beginRemoteLinkAuthorization, completeRemoteLinkAuthorization, revokeRemoteLinkAuthorization, isRemoteLinkCredential, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
3
4
  import { listDesktopLinkProviders } from "./catalog.js";
4
- import { readRemoteProviderCapabilities, REMOTE_LINK_ADAPTERS, } from "./remote-catalog.js";
5
+ import { authorizationDiagnosticFailure, authorizationDiagnosticIdentity, authorizationDiagnosticStage, withLinkAuthorizationTrace, } from "./diagnostics.js";
6
+ import { readRemoteProviderCapabilities, RemoteLinkCatalogError, REMOTE_LINK_ADAPTERS, } from "./remote-catalog.js";
5
7
  import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
6
8
  const messages = {
7
9
  invalid_request: "Link 请求参数无效。",
@@ -27,6 +29,64 @@ export class LinkServiceError extends Error {
27
29
  this.name = "LinkServiceError";
28
30
  }
29
31
  }
32
+ /** Trusted Host adapters use this to expose reviewed failure stages, never raw provider text. */
33
+ export class HostLinkOAuthError extends LinkServiceError {
34
+ failure;
35
+ constructor(failure) {
36
+ super(422, "authorization_failed");
37
+ this.name = "HostLinkOAuthError";
38
+ if (![
39
+ "invalid_request",
40
+ "access_denied",
41
+ "timeout",
42
+ "network_error",
43
+ "server_error",
44
+ "protocol_error",
45
+ ].includes(failure.code) ||
46
+ ![
47
+ "validation",
48
+ "authorization",
49
+ "discovery_registration",
50
+ "authorization_callback",
51
+ "token_exchange",
52
+ "credential_verification",
53
+ ].includes(failure.stage) ||
54
+ (failure.verificationStage !== undefined &&
55
+ ![
56
+ "credential_binding",
57
+ "mcp_initialize",
58
+ "tool_discovery",
59
+ "account_verification",
60
+ "capability_verification",
61
+ ].includes(failure.verificationStage)) ||
62
+ (failure.reason !== undefined &&
63
+ ![
64
+ "binding_mismatch",
65
+ "cancelled",
66
+ "network_error",
67
+ "http_error",
68
+ "protocol_error",
69
+ "tool_unavailable",
70
+ "invalid_pagination",
71
+ "response_too_large",
72
+ "account_tool_error",
73
+ "account_identity_unavailable",
74
+ "no_read_capabilities",
75
+ ].includes(failure.reason)) ||
76
+ (failure.httpStatus !== undefined &&
77
+ (!Number.isInteger(failure.httpStatus) ||
78
+ failure.httpStatus < 400 ||
79
+ failure.httpStatus > 599)))
80
+ throw new LinkServiceError(400, "invalid_request");
81
+ this.failure = Object.freeze({
82
+ code: failure.code,
83
+ stage: failure.stage,
84
+ ...(failure.verificationStage ? { verificationStage: failure.verificationStage } : {}),
85
+ ...(failure.reason ? { reason: failure.reason } : {}),
86
+ ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}),
87
+ });
88
+ }
89
+ }
30
90
  export function publicLinkError(error) {
31
91
  if (error instanceof LinkServiceError)
32
92
  return error;
@@ -51,8 +111,20 @@ export function createLinkService(options = {}) {
51
111
  const jobs = new Map();
52
112
  const remoteJobs = new Map();
53
113
  const interactiveJobs = new Map();
114
+ const hostOAuthJobs = new Map();
115
+ const hostOAuthAdapters = options.hostOAuthAdapters ?? [];
54
116
  let remoteCatalog;
55
- let catalogPending;
117
+ const catalogPending = new Map();
118
+ const catalogCodes = new Map([
119
+ ["network", "catalog_network"],
120
+ ["timeout", "catalog_timeout"],
121
+ ["http_status", "catalog_http_status"],
122
+ ["invalid_json", "catalog_invalid_json"],
123
+ ["invalid_schema", "catalog_invalid_schema"],
124
+ ["unreviewed_capability", "catalog_unreviewed_capability"],
125
+ ["unsupported_auth", "catalog_unsupported_auth"],
126
+ ["response_limit", "catalog_response_limit"],
127
+ ]);
56
128
  const revokedOwners = new Set();
57
129
  let closed = false;
58
130
  let snapshotSignature = "";
@@ -105,16 +177,54 @@ export function createLinkService(options = {}) {
105
177
  throw new LinkServiceError(401, "login_required");
106
178
  }
107
179
  async function authorize(context) {
108
- unavailable(context);
109
- if (!(await context.authorize()))
180
+ try {
181
+ unavailable(context);
182
+ if (!(await context.authorize()))
183
+ throw new LinkServiceError(401, "login_required");
184
+ unavailable(context);
185
+ }
186
+ catch (error) {
187
+ authorizationDiagnosticFailure("authorization", "authorization_check_failed");
188
+ throw error;
189
+ }
190
+ }
191
+ function remoteAuthority(context) {
192
+ if (!options.captureRemoteAuthority)
193
+ return context;
194
+ let lease;
195
+ try {
196
+ lease = options.captureRemoteAuthority();
197
+ lease.signal.throwIfAborted();
198
+ lease.current();
199
+ }
200
+ catch {
201
+ authorizationDiagnosticFailure("authorization", "authorization_check_failed");
110
202
  throw new LinkServiceError(401, "login_required");
111
- unavailable(context);
203
+ }
204
+ const signal = context.signal ? AbortSignal.any([context.signal, lease.signal]) : lease.signal;
205
+ const current = () => {
206
+ try {
207
+ signal.throwIfAborted();
208
+ lease.current();
209
+ return true;
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ };
215
+ return {
216
+ ownerId: context.ownerId,
217
+ signal,
218
+ authorize: async () => current() && (await context.authorize()) && current(),
219
+ };
112
220
  }
113
221
  function begin(context) {
114
222
  unavailable(context);
115
223
  if (operations.size >= 8 ||
116
- [...operations].filter((operation) => operation.ownerId === context.ownerId).length >= 2)
224
+ [...operations].filter((operation) => operation.ownerId === context.ownerId).length >= 2) {
225
+ authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
117
226
  throw new LinkServiceError(409, "busy");
227
+ }
118
228
  const operation = { ownerId: context.ownerId, controller: new AbortController() };
119
229
  operations.add(operation);
120
230
  return operation;
@@ -132,31 +242,46 @@ export function createLinkService(options = {}) {
132
242
  return current.revision;
133
243
  }
134
244
  function records() {
135
- const current = store.listWithStatus("full");
136
- if (!current.readable)
137
- throw new LinkServiceError(503, "unavailable");
138
- return {
139
- credentials: current.credentials,
140
- projectIds: new Set(store.list("project").map((c) => c.id)),
141
- };
245
+ authorizationDiagnosticStage("host_store_read");
246
+ try {
247
+ const current = store.listWithStatus("full");
248
+ if (!current.readable) {
249
+ authorizationDiagnosticFailure("host_store_read", "host_store_unreadable");
250
+ throw new LinkServiceError(503, "unavailable");
251
+ }
252
+ return {
253
+ credentials: current.credentials,
254
+ projectIds: new Set(store.list("project").map((c) => c.id)),
255
+ };
256
+ }
257
+ catch (error) {
258
+ if (!(error instanceof LinkServiceError))
259
+ authorizationDiagnosticFailure("host_store_read", "host_store_read_failed");
260
+ throw error;
261
+ }
142
262
  }
143
263
  function isLink(credential) {
144
264
  return (isRemoteLinkCredential(credential) ||
145
265
  (credential.type === "link" &&
146
- credential.meta?.linkExecutionRuntime === "local" &&
266
+ linkConnectionMetadata(credential.meta).linkExecutionRuntime === "local" &&
267
+ providers.some((provider) => provider.id === credential.meta?.linkProvider)) ||
268
+ (credential.type === "oauth" &&
269
+ linkConnectionMetadata(credential.meta).linkExecutionBackend === "mcp" &&
270
+ linkConnectionMetadata(credential.meta).linkExecutionRuntime === "local" &&
147
271
  providers.some((provider) => provider.id === credential.meta?.linkProvider)));
148
272
  }
149
273
  function masked(credential, scope) {
150
- const meta = credential.meta;
151
- const oauth = meta.linkAuthSource === "browser-oauth"
274
+ const meta = linkConnectionMetadata(credential.meta);
275
+ const oauth = meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth"
152
276
  ? summarizeOAuthCredentialSecret(credential.secret)
153
277
  : undefined;
154
278
  const remote = isRemoteLinkCredential(credential);
155
279
  const status = (remote && meta.linkRemoteState !== "connected") ||
156
- (meta.linkAuthSource === "browser-oauth" &&
280
+ ((meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth") &&
157
281
  meta.linkOAuthState &&
158
282
  meta.linkOAuthState !== "connected") ||
159
- (meta.linkAuthSource === "browser-oauth" && meta.linkCapabilityIds?.length === 0)
283
+ ((meta.linkAuthSource === "browser-oauth" || meta.linkAuthSource === "mcp-oauth") &&
284
+ meta.linkCapabilityIds?.length === 0)
160
285
  ? "unavailable"
161
286
  : !isCredentialSecretAvailable(credential.secret)
162
287
  ? "unavailable"
@@ -175,10 +300,29 @@ export function createLinkService(options = {}) {
175
300
  ? "remote-link"
176
301
  : meta.linkExecutionBackend === "cli"
177
302
  ? "cli-session"
178
- : meta.linkAuthSource === "browser-oauth"
179
- ? "browser-oauth"
180
- : "manual-token",
303
+ : meta.linkAuthSource === "mcp-oauth"
304
+ ? "mcp-oauth"
305
+ : meta.linkAuthSource === "browser-oauth"
306
+ ? "browser-oauth"
307
+ : "manual-token",
181
308
  status,
309
+ recipeId: meta.linkRecipeId,
310
+ connectionState: {
311
+ authorization: {
312
+ state: status === "connected"
313
+ ? "authorized"
314
+ : status === "expired" ||
315
+ status === "invalid" ||
316
+ meta.linkRemoteState === "reconnect" ||
317
+ meta.linkOAuthState === "reconnect"
318
+ ? "reauthorization_required"
319
+ : "unknown",
320
+ },
321
+ // Compatibility is operation-specific. A saved grant or a successful
322
+ // account check cannot prove the contract required by a future task.
323
+ compatibility: { state: "unknown" },
324
+ observation: { state: "unchecked" },
325
+ },
182
326
  scope,
183
327
  editable: scope === "user",
184
328
  revision: revision(credential),
@@ -208,19 +352,38 @@ export function createLinkService(options = {}) {
208
352
  for (const id of revisions.keys())
209
353
  if (!ids.has(id))
210
354
  revisions.delete(id);
211
- const remoteConfig = options.remoteLink?.();
355
+ const remoteConfig = remoteConfiguration();
212
356
  const remoteProviders = remoteConfig
213
357
  ? remoteCatalog?.issuer === remoteConfig.issuer
214
358
  ? remoteCatalog.providers
215
359
  : []
216
360
  : [];
361
+ const catalogHealth = !remoteConfig
362
+ ? { state: "unconfigured" }
363
+ : remoteCatalog?.issuer === remoteConfig.issuer
364
+ ? { ...remoteCatalog.health }
365
+ : { state: "unknown" };
366
+ const remoteReady = catalogHealth.state === "ready";
217
367
  const result = {
218
368
  providers: providers.map((provider) => {
219
369
  const deviceAuth = isLocalBrowserLinkProvider(provider.id)
220
370
  ? createBroker().status(provider.id)
221
371
  : undefined;
222
372
  const authModes = [];
373
+ for (const adapter of hostOAuthAdapters.filter((entry) => entry.providerId === provider.id))
374
+ authModes.push({
375
+ id: adapter.authModeId,
376
+ methodId: adapter.methodId,
377
+ kind: "redirect",
378
+ label: adapter.label,
379
+ preferred: true,
380
+ available: true,
381
+ });
223
382
  for (const method of provider.connectionMethods) {
383
+ if (hostOAuthAdapters.some((adapter) => adapter.providerId === provider.id && adapter.methodId === method.id))
384
+ continue;
385
+ if (method.oauthProfileId && method.browserAuth?.flow === "authorization-code-pkce")
386
+ continue;
224
387
  if (method.executionRuntime !== "local" || method.availability !== "available")
225
388
  continue;
226
389
  if (method.browserAuth?.flow === "device-code")
@@ -253,7 +416,10 @@ export function createLinkService(options = {}) {
253
416
  });
254
417
  }
255
418
  const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id &&
256
- remoteProviders.some((remote) => remote.id === adapter.id));
419
+ (remoteProviders.some((remote) => remote.id === adapter.id) ||
420
+ current.credentials.some((credential) => isRemoteLinkCredential(credential) &&
421
+ credential.meta?.linkProvider === provider.id)));
422
+ const remoteAvailable = remoteReady && remoteProviders.some((candidate) => candidate.id === provider.id);
257
423
  return {
258
424
  ...provider,
259
425
  connectionMethods: [
@@ -280,7 +446,16 @@ export function createLinkService(options = {}) {
280
446
  kind: "redirect",
281
447
  label: "网页授权",
282
448
  preferred: true,
283
- available: true,
449
+ available: remoteAvailable,
450
+ ...(!remoteAvailable
451
+ ? {
452
+ unavailableReason: !remoteConfig
453
+ ? "当前未配置 Link 服务。已有账号仍可管理。"
454
+ : remoteReady
455
+ ? "当前 Link 服务未提供此连接方式。已有账号仍可管理。"
456
+ : "连接目录暂时不可用。已有账号仍可管理,请稍后重试。",
457
+ }
458
+ : {}),
284
459
  },
285
460
  ]
286
461
  : []),
@@ -296,10 +471,11 @@ export function createLinkService(options = {}) {
296
471
  token: true,
297
472
  cliBinding: true,
298
473
  deviceAuth: true,
299
- remoteAuth: remoteProviders.length > 0,
474
+ remoteAuth: remoteReady && remoteProviders.length > 0,
300
475
  authorizationSteps: 1,
301
476
  },
302
477
  ...(remoteConfig ? { remoteServer: { issuer: remoteConfig.issuer } } : {}),
478
+ remoteCatalog: catalogHealth,
303
479
  remoteCleanupPending: store.remoteLinkRetirementCount(),
304
480
  };
305
481
  const signature = JSON.stringify(result);
@@ -309,14 +485,29 @@ export function createLinkService(options = {}) {
309
485
  }
310
486
  return { ...result, revision: snapshotRevision };
311
487
  }
312
- async function refreshRemoteCatalog(context) {
488
+ function remoteConfiguration() {
489
+ authorizationDiagnosticStage("remote_configuration");
490
+ try {
491
+ return options.remoteLink?.();
492
+ }
493
+ catch (error) {
494
+ authorizationDiagnosticFailure("remote_configuration", "remote_configuration_invalid");
495
+ throw error;
496
+ }
497
+ }
498
+ async function refreshRemoteCatalogWithDiagnostic(context, force = false) {
313
499
  await authorize(context);
314
- const config = options.remoteLink?.();
315
- if (!config || (remoteCatalog?.issuer === config.issuer && remoteCatalog.expiresAt > now()))
500
+ const config = remoteConfiguration();
501
+ if (!config ||
502
+ (!force && remoteCatalog?.issuer === config.issuer && remoteCatalog.expiresAt > now()))
316
503
  return;
317
- if (!catalogPending) {
318
- catalogPending = (async () => {
319
- let available;
504
+ authorizationDiagnosticStage("remote_catalog");
505
+ if (!catalogPending.has(config.issuer)) {
506
+ const pending = (async () => {
507
+ const previous = remoteCatalog?.issuer === config.issuer ? remoteCatalog : undefined;
508
+ let available = previous?.providers ?? [];
509
+ let health;
510
+ let diagnostic;
320
511
  try {
321
512
  available = options.readRemoteCatalog
322
513
  ? (await options.readRemoteCatalog(config)).flatMap((id) => {
@@ -326,23 +517,54 @@ export function createLinkService(options = {}) {
326
517
  : [];
327
518
  })
328
519
  : await readRemoteProviderCapabilities(config);
520
+ const checkedAt = new Date(now()).toISOString();
521
+ health = { state: "ready", checkedAt, lastSuccessAt: checkedAt };
329
522
  }
330
- catch {
331
- // Preserve the reviewed v1 behavior during temporary service/network failures.
332
- available = remoteCatalog?.issuer === config.issuer ? remoteCatalog.providers : [];
523
+ catch (error) {
524
+ diagnostic = {
525
+ internalCode: error instanceof RemoteLinkCatalogError
526
+ ? (catalogCodes.get(error.reason) ?? "catalog_unexpected_error")
527
+ : "catalog_unexpected_error",
528
+ ...(error instanceof RemoteLinkCatalogError
529
+ ? { upstreamStatus: error.httpStatus }
530
+ : {}),
531
+ };
532
+ // A failed discovery does not revoke saved accounts or erase the last good catalog.
533
+ health = {
534
+ state: previous?.health.lastSuccessAt ? "stale" : "unavailable",
535
+ checkedAt: new Date(now()).toISOString(),
536
+ ...(previous?.health.lastSuccessAt
537
+ ? { lastSuccessAt: previous.health.lastSuccessAt }
538
+ : {}),
539
+ errorCode: error instanceof RemoteLinkCatalogError ? error.code : "network_error",
540
+ ...(error instanceof RemoteLinkCatalogError && error.httpStatus !== undefined
541
+ ? { httpStatus: error.httpStatus }
542
+ : {}),
543
+ };
333
544
  }
334
545
  if (!closed && options.remoteLink?.()?.issuer === config.issuer)
335
546
  remoteCatalog = {
336
547
  issuer: config.issuer,
337
- expiresAt: now() + 60_000,
548
+ expiresAt: now() + (health.state === "ready" ? 60_000 : 15_000),
338
549
  providers: available,
550
+ health,
339
551
  };
552
+ return diagnostic;
340
553
  })().finally(() => {
341
- catalogPending = undefined;
554
+ if (catalogPending.get(config.issuer) === pending)
555
+ catalogPending.delete(config.issuer);
342
556
  });
557
+ catalogPending.set(config.issuer, pending);
343
558
  }
344
- await catalogPending;
559
+ // The shared request performs no trace writes. Every waiter owns its failure correlation.
560
+ const diagnostic = await catalogPending.get(config.issuer);
561
+ if (diagnostic)
562
+ authorizationDiagnosticFailure("remote_catalog", diagnostic.internalCode, diagnostic.upstreamStatus);
345
563
  await authorize(context);
564
+ return diagnostic;
565
+ }
566
+ async function refreshRemoteCatalog(context, force = false) {
567
+ await refreshRemoteCatalogWithDiagnostic(context, force);
346
568
  }
347
569
  function string(value, max = 200) {
348
570
  if (typeof value !== "string" ||
@@ -359,7 +581,9 @@ export function createLinkService(options = {}) {
359
581
  return id;
360
582
  }
361
583
  function reviewed(id, expectedRevision) {
584
+ authorizationDiagnosticStage("connection_review");
362
585
  const current = records();
586
+ authorizationDiagnosticStage("connection_review");
363
587
  const credential = current.credentials.find((entry) => entry.id === id);
364
588
  if (!credential || !isLink(credential))
365
589
  throw new LinkServiceError(409, "conflict");
@@ -369,7 +593,8 @@ export function createLinkService(options = {}) {
369
593
  throw new LinkServiceError(409, "conflict");
370
594
  return credential;
371
595
  }
372
- function prepare(raw) {
596
+ function prepare(raw, hostAdapter) {
597
+ authorizationDiagnosticStage("input_validation");
373
598
  if (!raw || typeof raw !== "object")
374
599
  throw new LinkServiceError(400, "invalid_request");
375
600
  const input = {
@@ -384,11 +609,27 @@ export function createLinkService(options = {}) {
384
609
  ?.connectionMethods.find((candidate) => candidate.id === input.methodId);
385
610
  if (!method || method.executionRuntime !== "local" || method.availability !== "available")
386
611
  throw new LinkServiceError(400, "invalid_request");
387
- const id = input.connectionId ?? `link-${input.providerId}-${input.methodId}`;
612
+ if (method.oauthProfileId &&
613
+ method.browserAuth?.flow === "authorization-code-pkce" &&
614
+ (!hostAdapter ||
615
+ hostAdapter.providerId !== input.providerId ||
616
+ hostAdapter.methodId !== input.methodId))
617
+ throw new LinkServiceError(400, "invalid_request");
618
+ const id = input.connectionId ?? `link-${randomUUID()}`;
619
+ authorizationDiagnosticStage("connection_review");
388
620
  let expected = null;
621
+ let knownConnectionIds;
389
622
  if (input.expectedRevision === null) {
390
- if (records().credentials.some((credential) => credential.id === id))
623
+ const current = records();
624
+ authorizationDiagnosticStage("connection_review");
625
+ if (current.credentials.some((credential) => credential.id === id))
391
626
  throw new LinkServiceError(409, "conflict");
627
+ if (!input.connectionId)
628
+ knownConnectionIds = current.credentials
629
+ .filter((credential) => credential.meta?.linkProvider === input.providerId &&
630
+ credential.meta?.linkConnectionMethod === input.methodId)
631
+ .map((credential) => credential.id)
632
+ .sort();
392
633
  }
393
634
  else {
394
635
  expected = reviewed(id, input.expectedRevision);
@@ -396,7 +637,7 @@ export function createLinkService(options = {}) {
396
637
  expected.meta.linkConnectionMethod !== input.methodId)
397
638
  throw new LinkServiceError(409, "conflict");
398
639
  }
399
- return { input, id, expected };
640
+ return { input, id, expected, ...(knownConnectionIds ? { knownConnectionIds } : {}) };
400
641
  }
401
642
  async function change(context, write, bypassBusy = false) {
402
643
  const action = async () => {
@@ -413,6 +654,15 @@ export function createLinkService(options = {}) {
413
654
  const current = records();
414
655
  if (current.projectIds.has(prepared.id))
415
656
  throw new LinkServiceError(403, "read_only");
657
+ if (prepared.knownConnectionIds) {
658
+ const currentIds = current.credentials
659
+ .filter((credential) => credential.meta?.linkProvider === prepared.input.providerId &&
660
+ credential.meta?.linkConnectionMethod === prepared.input.methodId)
661
+ .map((credential) => credential.id)
662
+ .sort();
663
+ if (JSON.stringify(currentIds) !== JSON.stringify(prepared.knownConnectionIds))
664
+ throw new LinkServiceError(409, "conflict");
665
+ }
416
666
  if (!store.compareAndSwap("user", prepared.id, prepared.expected, next, retirement))
417
667
  throw new LinkServiceError(409, "conflict");
418
668
  }
@@ -426,9 +676,12 @@ export function createLinkService(options = {}) {
426
676
  meta: {
427
677
  linkProvider: prepared.input.providerId,
428
678
  linkConnectionMethod: prepared.input.methodId,
429
- linkExecutionRuntime: "local",
430
- linkAuthSource: authSource,
431
- linkExecutionBackend: authSource === "cli-session" ? "cli" : "http-token",
679
+ recordSchemaVersion: 2,
680
+ linkRecipeId: authSource === "cli-session"
681
+ ? "cli-session"
682
+ : authSource === "browser-oauth"
683
+ ? "api-oauth"
684
+ : "api-token",
432
685
  agentExposable: false,
433
686
  linkAccountId: validation.identity.externalAccountId,
434
687
  linkAccountLabel: validation.identity.label,
@@ -554,6 +807,7 @@ export function createLinkService(options = {}) {
554
807
  installed: result.installed,
555
808
  authenticated: result.authenticated,
556
809
  ...(result.account ? { account: result.account } : {}),
810
+ ...(result.accountId ? { accountId: result.accountId } : {}),
557
811
  };
558
812
  }
559
813
  finally {
@@ -572,6 +826,14 @@ export function createLinkService(options = {}) {
572
826
  async function disconnect(context, id, expectedRevision) {
573
827
  await authorize(context);
574
828
  const credential = reviewed(safeId(id), expectedRevision);
829
+ if (linkConnectionMetadata(credential.meta).linkExecutionBackend === "mcp") {
830
+ const adapter = hostOAuthAdapters.find((entry) => entry.providerId === credential.meta?.linkProvider &&
831
+ entry.methodId === credential.meta?.linkConnectionMethod);
832
+ await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
833
+ // Local authority is already gone. Cleanup never deletes a new connection with this ID.
834
+ await adapter?.disconnect?.(credential).catch(() => { });
835
+ return;
836
+ }
575
837
  if (isRemoteLinkCredential(credential)) {
576
838
  // Disable local use before external I/O. Retain the disabled record if remote revocation
577
839
  // fails so the user can retry; never report successful remote revocation on a timeout.
@@ -669,19 +931,237 @@ export function createLinkService(options = {}) {
669
931
  }
670
932
  return cliStep(job, job.public.expiresAt, status.installed, validation);
671
933
  }
934
+ async function startHostOAuth(context, input, adapter) {
935
+ authorizationDiagnosticStage("job_creation");
936
+ for (const [id, job] of hostOAuthJobs)
937
+ if (job.retireAt !== undefined && job.retireAt <= now())
938
+ hostOAuthJobs.delete(id);
939
+ if (hostOAuthJobs.size >= 32) {
940
+ authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
941
+ throw new LinkServiceError(409, "busy");
942
+ }
943
+ const prepared = prepare(input, adapter);
944
+ authorizationDiagnosticStage("job_creation");
945
+ const operation = begin(context);
946
+ const expiresAt = new Date(now() + 10 * 60_000).toISOString();
947
+ const job = {
948
+ context,
949
+ adapter,
950
+ prepared,
951
+ operation,
952
+ public: {
953
+ id: randomUUID(),
954
+ providerId: input.providerId,
955
+ methodId: input.methodId,
956
+ authModeId: adapter.authModeId,
957
+ state: "pending",
958
+ expiresAt,
959
+ step: { id: randomUUID(), kind: "processing", expiresAt },
960
+ },
961
+ };
962
+ hostOAuthJobs.set(job.public.id, job);
963
+ let ready;
964
+ const preparedStep = new Promise((resolve) => {
965
+ ready = resolve;
966
+ });
967
+ const end = (code) => {
968
+ if (job.public.state !== "pending")
969
+ return;
970
+ operation.controller.abort();
971
+ job.public = terminal(job.public, code === "cancelled" ? "cancelled" : "failed", {
972
+ errorCode: code,
973
+ });
974
+ job.retireAt = now() + 5 * 60_000;
975
+ clearTimeout(job.timer);
976
+ ready();
977
+ };
978
+ job.cancel = end;
979
+ const onAbort = () => end("cancelled");
980
+ context.signal?.addEventListener("abort", onAbort, { once: true });
981
+ job.timer = setTimeout(() => end("authorization_expired"), 10 * 60_000);
982
+ job.timer.unref?.();
983
+ if (context.signal?.aborted)
984
+ end("cancelled");
985
+ void (async () => {
986
+ let candidate;
987
+ let committed = false;
988
+ try {
989
+ operation.controller.signal.throwIfAborted();
990
+ candidate = await adapter.authorize({
991
+ id: prepared.id,
992
+ label: prepared.input.label,
993
+ previous: prepared.expected,
994
+ signal: operation.controller.signal,
995
+ onAuthorizationUrl: async (url, expiry) => {
996
+ await authorize(context);
997
+ operation.controller.signal.throwIfAborted();
998
+ const target = new URL(url);
999
+ const time = Date.parse(expiry);
1000
+ if (target.protocol !== "https:" ||
1001
+ target.username ||
1002
+ target.password ||
1003
+ target.hash ||
1004
+ !Number.isFinite(time) ||
1005
+ time <= now() ||
1006
+ time > now() + 10 * 60_000 + 1_000)
1007
+ throw new LinkServiceError(422, "authorization_failed");
1008
+ if (job.public.state !== "pending")
1009
+ throw new LinkServiceError(409, "cancelled");
1010
+ job.public.expiresAt = expiry;
1011
+ clearTimeout(job.timer);
1012
+ job.timer = setTimeout(() => end("authorization_expired"), Math.max(1, time - now()));
1013
+ job.timer.unref?.();
1014
+ job.public.step = {
1015
+ id: randomUUID(),
1016
+ kind: "redirect",
1017
+ expiresAt: expiry,
1018
+ authorizationUrl: target.href,
1019
+ };
1020
+ ready();
1021
+ },
1022
+ });
1023
+ await authorize(context);
1024
+ operation.controller.signal.throwIfAborted();
1025
+ if (job.public.state !== "pending" ||
1026
+ candidate.id !== prepared.id ||
1027
+ candidate.type !== "oauth" ||
1028
+ candidate.meta?.linkProvider !== input.providerId ||
1029
+ candidate.meta.linkConnectionMethod !== input.methodId ||
1030
+ linkConnectionMetadata(candidate.meta).linkExecutionBackend !== "mcp" ||
1031
+ candidate.meta.agentExposable !== false)
1032
+ throw new LinkServiceError(422, "provider_rejected");
1033
+ // Host adapters may still return legacy routing fields. The installed
1034
+ // connection keeps only its reviewed recipe; presentation derives axes.
1035
+ const { linkExecutionRuntime: _runtime, linkExecutionBackend: _backend, linkAuthSource: _source, ...candidateMeta } = candidate.meta;
1036
+ candidate = {
1037
+ ...candidate,
1038
+ meta: { ...candidateMeta, recordSchemaVersion: 2, linkRecipeId: "mcp-oauth" },
1039
+ };
1040
+ await change(context, () => {
1041
+ operation.controller.signal.throwIfAborted();
1042
+ if (job.public.state !== "pending")
1043
+ throw new LinkServiceError(409, "cancelled");
1044
+ swap(prepared, candidate);
1045
+ committed = true;
1046
+ job.public = terminal(job.public, "connected", {
1047
+ connection: masked(candidate, "user"),
1048
+ });
1049
+ });
1050
+ }
1051
+ catch (error) {
1052
+ if (job.public.state === "pending") {
1053
+ end(operation.controller.signal.aborted
1054
+ ? "cancelled"
1055
+ : error instanceof LinkServiceError
1056
+ ? error.code
1057
+ : "authorization_failed");
1058
+ if (job.public.state === "failed" &&
1059
+ error instanceof HostLinkOAuthError)
1060
+ job.public = { ...job.public, failure: { ...error.failure } };
1061
+ }
1062
+ }
1063
+ finally {
1064
+ if (candidate?.secret && !committed) {
1065
+ try {
1066
+ const saved = store.resolve(candidate.id, "full");
1067
+ if (candidate.secret !== prepared.expected?.secret &&
1068
+ candidate.secret !== saved?.secret)
1069
+ await adapter.disconnect?.(candidate);
1070
+ }
1071
+ catch {
1072
+ /* Detached cleanup cannot change the original authorization result. */
1073
+ }
1074
+ }
1075
+ clearTimeout(job.timer);
1076
+ context.signal?.removeEventListener("abort", onAbort);
1077
+ job.retireAt = now() + 5 * 60_000;
1078
+ finish(operation);
1079
+ ready();
1080
+ }
1081
+ })();
1082
+ await preparedStep;
1083
+ return structuredClone(job.public);
1084
+ }
1085
+ async function openHostAuthorization(context, id) {
1086
+ await authorize(context);
1087
+ const job = hostOAuthJobs.get(safeId(id));
1088
+ if (!job)
1089
+ return false;
1090
+ if (job.context.ownerId !== context.ownerId ||
1091
+ job.public.state !== "pending" ||
1092
+ job.operation.controller.signal.aborted ||
1093
+ job.public.step?.kind !== "redirect" ||
1094
+ Date.parse(job.public.step.expiresAt) <= now())
1095
+ throw new LinkServiceError(409, "conflict");
1096
+ const target = new URL(job.public.step.authorizationUrl);
1097
+ if (target.protocol !== "https:" || target.username || target.password || target.hash)
1098
+ throw new LinkServiceError(400, "invalid_request");
1099
+ await job.adapter.openAuthorizationUrl(target.href);
1100
+ await authorize(context);
1101
+ return true;
1102
+ }
1103
+ function diagnosticIdentity(input, authModeId) {
1104
+ const provider = providers.find((candidate) => candidate.id === input?.providerId);
1105
+ if (!provider)
1106
+ return;
1107
+ const remote = REMOTE_LINK_ADAPTERS.find((adapter) => adapter.id === provider.id && adapter.methodId === input.methodId);
1108
+ const method = provider.connectionMethods.find((candidate) => candidate.id === input.methodId);
1109
+ const modes = remote
1110
+ ? [remote.authModeId]
1111
+ : method
1112
+ ? [
1113
+ "token",
1114
+ ...(method.quickAuth ? ["cli-session"] : []),
1115
+ ...(method.browserAuth?.flow === "device-code" ? ["browser-oauth"] : []),
1116
+ ...(method.oauthProfileId ? ["mcp-oauth"] : []),
1117
+ ]
1118
+ : [];
1119
+ authorizationDiagnosticIdentity({
1120
+ providerId: provider.id,
1121
+ ...(remote || method ? { methodId: remote?.methodId ?? method.id } : {}),
1122
+ ...(modes.includes(authModeId) ? { authModeId: authModeId } : {}),
1123
+ });
1124
+ }
672
1125
  async function startAuthorization(context, input, rawAuthModeId) {
1126
+ return withLinkAuthorizationTrace(options.onAuthorizationDiagnostic, () => startAuthorizationWithinTrace(context, input, rawAuthModeId), publicLinkError);
1127
+ }
1128
+ async function startAuthorizationWithinTrace(context, input, rawAuthModeId) {
673
1129
  await authorize(context);
1130
+ diagnosticIdentity(input, rawAuthModeId);
1131
+ authorizationDiagnosticStage("input_validation");
674
1132
  const authModeId = string(rawAuthModeId, 80);
675
- if (input?.resourceUrl !== undefined && authModeId !== "remote-link")
1133
+ const hostAdapter = hostOAuthAdapters.find((adapter) => adapter.providerId === input?.providerId &&
1134
+ adapter.methodId === input.methodId &&
1135
+ adapter.authModeId === authModeId);
1136
+ if (input?.resourceUrl !== undefined && authModeId !== "remote-link" && !hostAdapter)
676
1137
  throw new LinkServiceError(400, "invalid_request");
677
- if (authModeId === "remote-link")
678
- await refreshRemoteCatalog(context);
1138
+ if (authModeId === "remote-link") {
1139
+ context = remoteAuthority(context);
1140
+ const diagnostic = await refreshRemoteCatalogWithDiagnostic(context, true);
1141
+ const state = snapshot().remoteCatalog?.state;
1142
+ if (state !== "ready") {
1143
+ if (state === "unconfigured")
1144
+ authorizationDiagnosticFailure("remote_configuration", "remote_configuration_missing");
1145
+ else
1146
+ authorizationDiagnosticFailure("remote_catalog", diagnostic?.internalCode ?? "catalog_unavailable", diagnostic?.upstreamStatus);
1147
+ throw new LinkServiceError(503, "unavailable");
1148
+ }
1149
+ }
679
1150
  const provider = snapshot().providers.find((candidate) => candidate.id === input?.providerId);
1151
+ authorizationDiagnosticStage("mode_selection");
680
1152
  const mode = provider?.authModes?.find((candidate) => candidate.id === authModeId && candidate.methodId === input.methodId && candidate.available);
681
- if (!mode)
1153
+ if (!mode) {
1154
+ authorizationDiagnosticFailure("mode_selection", authModeId === "remote-link" &&
1155
+ REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input?.providerId) &&
1156
+ !remoteCatalog?.providers.some((candidate) => candidate.id === input.providerId)
1157
+ ? "provider_capability_missing"
1158
+ : "auth_mode_unavailable");
682
1159
  throw new LinkServiceError(400, "invalid_request");
1160
+ }
1161
+ if (hostAdapter)
1162
+ return startHostOAuth(context, input, hostAdapter);
683
1163
  if (mode.kind === "redirect") {
684
- const result = await startRemoteAuth(context, input);
1164
+ const result = await startRemoteAuthAfterCatalog(context, input);
685
1165
  const job = remoteJobs.get(result.id);
686
1166
  job.public.methodId = input.methodId;
687
1167
  job.public.authModeId = authModeId;
@@ -691,10 +1171,14 @@ export function createLinkService(options = {}) {
691
1171
  }
692
1172
  if (mode.kind === "device-code")
693
1173
  return startDeviceAuth(context, input);
1174
+ authorizationDiagnosticStage("job_creation");
694
1175
  pruneInteractive();
695
- if (interactiveJobs.size >= 32)
1176
+ if (interactiveJobs.size >= 32) {
1177
+ authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
696
1178
  throw new LinkServiceError(409, "busy");
1179
+ }
697
1180
  const prepared = prepare(input);
1181
+ authorizationDiagnosticStage("job_creation");
698
1182
  const operation = begin(context);
699
1183
  const expiresAt = now() + 10 * 60_000;
700
1184
  const base = { id: randomUUID(), expiresAt: new Date(expiresAt).toISOString() };
@@ -954,14 +1438,18 @@ export function createLinkService(options = {}) {
954
1438
  }
955
1439
  }
956
1440
  async function startDeviceAuth(context, input) {
1441
+ authorizationDiagnosticStage("job_creation");
957
1442
  pruneJobs();
958
- if (jobs.size >= 32)
1443
+ if (jobs.size >= 32) {
1444
+ authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
959
1445
  throw new LinkServiceError(409, "busy");
1446
+ }
960
1447
  const operation = begin(context);
961
1448
  let job;
962
1449
  try {
963
1450
  await authorize(context);
964
1451
  const prepared = prepare(input);
1452
+ authorizationDiagnosticStage("job_creation");
965
1453
  const providerId = prepared.input.providerId;
966
1454
  if (!isLocalBrowserLinkProvider(providerId))
967
1455
  throw new LinkServiceError(400, "invalid_request");
@@ -1029,14 +1517,23 @@ export function createLinkService(options = {}) {
1029
1517
  }
1030
1518
  }
1031
1519
  async function startRemoteAuth(context, input) {
1520
+ context = remoteAuthority(context);
1521
+ await refreshRemoteCatalog(context, true);
1522
+ return startRemoteAuthAfterCatalog(context, input);
1523
+ }
1524
+ async function startRemoteAuthAfterCatalog(context, input) {
1032
1525
  await authorize(context);
1526
+ authorizationDiagnosticStage("job_creation");
1033
1527
  const checkCapacity = () => {
1034
1528
  pruneRemoteJobs();
1035
1529
  if (remoteJobs.size >= 32 ||
1036
- [...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
1530
+ [...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2) {
1531
+ authorizationDiagnosticFailure("job_creation", "job_capacity_exceeded");
1037
1532
  throw new LinkServiceError(409, "busy");
1533
+ }
1038
1534
  };
1039
1535
  checkCapacity();
1536
+ authorizationDiagnosticStage("input_validation");
1040
1537
  if (!input ||
1041
1538
  input.methodId !== "remote-link" ||
1042
1539
  !REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId) ||
@@ -1045,22 +1542,27 @@ export function createLinkService(options = {}) {
1045
1542
  typeof input.resourceUrl !== "string" ||
1046
1543
  !input.resourceUrl)))
1047
1544
  throw new LinkServiceError(400, "invalid_request");
1048
- const config = options.remoteLink?.();
1049
- if (!config)
1545
+ const config = remoteConfiguration();
1546
+ if (!config) {
1547
+ authorizationDiagnosticFailure("remote_configuration", "remote_configuration_missing");
1050
1548
  throw new LinkServiceError(503, "unavailable");
1051
- await refreshRemoteCatalog(context);
1052
- // Discovery awaits must not bypass owner revocation or the pending-job limit.
1053
- await authorize(context);
1054
- checkCapacity();
1055
- const capabilities = remoteCatalog?.issuer === config.issuer
1549
+ }
1550
+ // No await between selecting the deployment and creating its authorization URL.
1551
+ authorizationDiagnosticStage("remote_catalog");
1552
+ const capabilities = remoteCatalog?.issuer === config.issuer && remoteCatalog.health.state === "ready"
1056
1553
  ? remoteCatalog.providers.find((provider) => provider.id === input.providerId)
1057
1554
  : undefined;
1058
- if (!capabilities)
1555
+ if (!capabilities) {
1556
+ authorizationDiagnosticFailure("remote_catalog", "provider_capability_missing");
1059
1557
  throw new LinkServiceError(503, "unavailable");
1558
+ }
1559
+ authorizationDiagnosticStage("connection_review");
1060
1560
  const id = input.connectionId === undefined ? `link-remote-${randomUUID()}` : safeId(input.connectionId);
1061
1561
  let expected = null;
1062
1562
  if (input.expectedRevision === null) {
1063
- if (records().credentials.some((credential) => credential.id === id))
1563
+ const current = records();
1564
+ authorizationDiagnosticStage("connection_review");
1565
+ if (current.credentials.some((credential) => credential.id === id))
1064
1566
  throw new LinkServiceError(409, "conflict");
1065
1567
  }
1066
1568
  else {
@@ -1069,6 +1571,7 @@ export function createLinkService(options = {}) {
1069
1571
  throw new LinkServiceError(409, "conflict");
1070
1572
  }
1071
1573
  const prepared = { id, expected, input: { ...input, label: string(input.label) } };
1574
+ authorizationDiagnosticStage("job_creation");
1072
1575
  let attempt;
1073
1576
  try {
1074
1577
  let resourceFiles;
@@ -1089,6 +1592,7 @@ export function createLinkService(options = {}) {
1089
1592
  });
1090
1593
  }
1091
1594
  catch {
1595
+ authorizationDiagnosticFailure("job_creation", "remote_authorization_invalid");
1092
1596
  throw new LinkServiceError(400, "invalid_request");
1093
1597
  }
1094
1598
  const publicJob = {
@@ -1246,6 +1750,12 @@ export function createLinkService(options = {}) {
1246
1750
  pruneJobs();
1247
1751
  pruneRemoteJobs();
1248
1752
  pruneInteractive();
1753
+ const host = hostOAuthJobs.get(safeId(id));
1754
+ if (host) {
1755
+ if (host.context.ownerId !== context.ownerId)
1756
+ throw new LinkServiceError(404, "not_found");
1757
+ return structuredClone(host.public);
1758
+ }
1249
1759
  const interactive = interactiveJobs.get(safeId(id));
1250
1760
  if (interactive) {
1251
1761
  if (interactive.context.ownerId !== context.ownerId)
@@ -1258,6 +1768,8 @@ export function createLinkService(options = {}) {
1258
1768
  if (remote) {
1259
1769
  if (remote.context.ownerId !== context.ownerId)
1260
1770
  throw new LinkServiceError(404, "not_found");
1771
+ if (options.captureRemoteAuthority && remote.public.state === "pending")
1772
+ await authorize(remote.context);
1261
1773
  return structuredClone(remote.public);
1262
1774
  }
1263
1775
  const job = jobs.get(safeId(id));
@@ -1268,11 +1780,16 @@ export function createLinkService(options = {}) {
1268
1780
  async function cancelAuthorization(context, id) {
1269
1781
  await authorize(context);
1270
1782
  safeId(id);
1271
- const ownerId = remoteJobs.get(id)?.context.ownerId ??
1783
+ const ownerId = hostOAuthJobs.get(id)?.context.ownerId ??
1784
+ remoteJobs.get(id)?.context.ownerId ??
1272
1785
  interactiveJobs.get(id)?.context.ownerId ??
1273
1786
  jobs.get(id)?.ownerId;
1274
1787
  if (!ownerId || ownerId !== context.ownerId)
1275
1788
  throw new LinkServiceError(404, "not_found");
1789
+ const host = hostOAuthJobs.get(id);
1790
+ if (host?.public.state === "pending") {
1791
+ host.cancel?.("cancelled");
1792
+ }
1276
1793
  const remote = remoteJobs.get(id);
1277
1794
  if (remote?.public.state === "pending") {
1278
1795
  remote.public = terminal(remote.public, "cancelled", { errorCode: "cancelled" });
@@ -1288,6 +1805,13 @@ export function createLinkService(options = {}) {
1288
1805
  }
1289
1806
  function cancelOwner(ownerId) {
1290
1807
  revokedOwners.add(ownerId);
1808
+ for (const [id, job] of hostOAuthJobs)
1809
+ if (job.context.ownerId === ownerId) {
1810
+ job.cancel?.("cancelled");
1811
+ job.operation.controller.abort();
1812
+ clearTimeout(job.timer);
1813
+ hostOAuthJobs.delete(id);
1814
+ }
1291
1815
  for (const [id, job] of interactiveJobs)
1292
1816
  if (job.context.ownerId === ownerId) {
1293
1817
  endInteractive(job, "cancelled");
@@ -1312,6 +1836,12 @@ export function createLinkService(options = {}) {
1312
1836
  clearInterval(cleanupTimer);
1313
1837
  clearTimeout(cleanupStartup);
1314
1838
  remoteJobs.clear();
1839
+ for (const job of hostOAuthJobs.values()) {
1840
+ job.cancel?.("cancelled");
1841
+ job.operation.controller.abort();
1842
+ clearTimeout(job.timer);
1843
+ }
1844
+ hostOAuthJobs.clear();
1315
1845
  for (const job of interactiveJobs.values())
1316
1846
  endInteractive(job, "cancelled");
1317
1847
  interactiveJobs.clear();
@@ -1333,6 +1863,7 @@ export function createLinkService(options = {}) {
1333
1863
  disconnect,
1334
1864
  startDeviceAuth,
1335
1865
  startAuthorization,
1866
+ openHostAuthorization,
1336
1867
  respondAuthorization,
1337
1868
  refreshRemoteCatalog,
1338
1869
  startRemoteAuth,