@privos_ai/app-server 0.7.3 → 0.8.1

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.
@@ -4,13 +4,112 @@ import { INVALID_REQUEST, PARSE_ERROR, errorResponse, jsonRpcError, } from '../p
4
4
  import { buildHubUserTokenAuthOptions, extractRelayUserTokenCredential, } from './hub-user-token-actor.js';
5
5
  import { AppServerRuntime, DEFAULT_MAX_MESSAGE_BYTES, relayCallerAuthSurface, resolveCallerCredential, } from '../runtime.js';
6
6
  import { MessageTooLargeError, rawDataToText } from './message-adapter.js';
7
- import { isStandaloneControlMethod, } from './standalone-control.js';
8
- import { resolveIdentityFilePath, saveStandaloneIdentity, standaloneHubFingerprint, standaloneIdentityFileExists, StandaloneIdentityError, } from './standalone-identity.js';
7
+ import { isStandaloneControlMethod, STANDALONE_AGENT_BOT_CREDENTIAL_METHOD, } from './standalone-control.js';
8
+ import { consumeStandalonePendingIdentity, loadStandaloneIdentity, loadStandalonePendingIdentity, resolveIdentityFilePath, saveStandaloneIdentity, saveStandalonePendingIdentity, standaloneHubFingerprint, standaloneIdentityFileExists, StandaloneIdentityError, } from './standalone-identity.js';
9
+ import { lintManifest, sha256CanonicalJson } from '../manifest-tools.js';
9
10
  import { assertRuntimeDispatchTrustConfigurationV3, extractRuntimeDispatchRelayEnvelopeV3, verifyRuntimeDispatchAssertionV3, } from '../workload/dispatch-assertion.js';
10
11
  const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
11
12
  const DEFAULT_PAIRING_TIMEOUT_MS = 30_000;
12
13
  const DEFAULT_OAUTH_TIMEOUT_MS = 15_000;
13
14
  const DEFAULT_OPEN_HANDSHAKE_TIMEOUT_MS = 15_000;
15
+ function sortedUniqueStrings(value, field) {
16
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item)) {
17
+ throw new Error(`Pairing response ${field} is invalid.`);
18
+ }
19
+ const result = [...value].sort();
20
+ if (new Set(result).size !== result.length)
21
+ throw new Error(`Pairing response ${field} contains duplicates.`);
22
+ return result;
23
+ }
24
+ function manifestDeclaredPermissionCeiling(manifest) {
25
+ if (!Array.isArray(manifest.permissions))
26
+ throw new Error('Published v3 manifest must declare permissions.');
27
+ return sortedUniqueStrings(manifest.permissions.map((permission) => permission && typeof permission === 'object' && !Array.isArray(permission)
28
+ ? permission.scope
29
+ : undefined), 'manifest permission ceiling');
30
+ }
31
+ async function persistCompletedPairing(input) {
32
+ assertRuntimeDispatchTrustConfigurationV3(input.wire.trust);
33
+ const trust = input.wire.trust;
34
+ const fingerprint = standaloneHubFingerprint(trust.hubKid);
35
+ if (input.wire.fingerprint !== undefined && input.wire.fingerprint !== fingerprint) {
36
+ throw new Error('Pairing response fingerprint does not match the pinned Hub key.');
37
+ }
38
+ if (trust.affinity.mcpAppId !== input.mcpAppId)
39
+ throw new Error('Pairing completion changed the app identity.');
40
+ if (input.wire.manifestDigest && input.wire.manifestDigest !== trust.affinity.manifestDigest) {
41
+ throw new Error('Pairing completion manifest digest does not match dispatch affinity.');
42
+ }
43
+ let approvedPermissionCeiling;
44
+ if (input.wire.approvedPermissionCeiling !== undefined) {
45
+ approvedPermissionCeiling = sortedUniqueStrings(input.wire.approvedPermissionCeiling, 'approvedPermissionCeiling');
46
+ }
47
+ if (input.pending) {
48
+ if (input.pending.clientId !== input.clientId ||
49
+ input.pending.oauthClientId !== input.wire.oauthClientId ||
50
+ input.pending.mcpAppId !== input.mcpAppId ||
51
+ input.pending.pairingId !== input.wire.pairingId ||
52
+ input.pending.manifestDigest !== trust.affinity.manifestDigest ||
53
+ input.pending.permissionContractHash !== input.wire.permissionContractHash ||
54
+ input.pending.hubKid !== trust.hubKid ||
55
+ input.pending.fingerprint !== fingerprint) {
56
+ throw new Error('Pairing completion does not match the durable pending identity.');
57
+ }
58
+ if (!approvedPermissionCeiling)
59
+ throw new Error('Pairing completion omitted the approved permission ceiling.');
60
+ const declared = new Set(input.pending.declaredPermissionCeiling);
61
+ if (approvedPermissionCeiling.some((scope) => !declared.has(scope))) {
62
+ throw new Error('Pairing completion widened the published permission ceiling.');
63
+ }
64
+ }
65
+ (input.options?.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before trusting dispatch from this Hub.`);
66
+ let identityFilePath;
67
+ if (input.options?.persistIdentityFile ?? true) {
68
+ const identity = {
69
+ pairingVersion: 2,
70
+ relayUrl: input.privosUrl,
71
+ clientId: input.clientId,
72
+ clientSecret: input.clientSecret,
73
+ trust,
74
+ fingerprint,
75
+ mcpAppId: input.mcpAppId,
76
+ pairedAt: Date.now(),
77
+ };
78
+ try {
79
+ identityFilePath = await saveStandaloneIdentity(identity, { filePath: input.options?.identityFilePath });
80
+ }
81
+ catch (error) {
82
+ const existing = loadStandaloneIdentity({ filePath: input.options?.identityFilePath });
83
+ if (existing.identity.relayUrl !== identity.relayUrl ||
84
+ existing.identity.clientId !== identity.clientId ||
85
+ existing.identity.clientSecret !== identity.clientSecret ||
86
+ existing.identity.mcpAppId !== identity.mcpAppId ||
87
+ JSON.stringify(existing.identity.trust) !== JSON.stringify(identity.trust)) {
88
+ throw error;
89
+ }
90
+ identityFilePath = existing.filePath;
91
+ }
92
+ if (input.pending) {
93
+ await consumeStandalonePendingIdentity(input.pending, {
94
+ filePath: input.options?.pendingIdentityFilePath,
95
+ finalIdentityFilePath: input.options?.identityFilePath,
96
+ });
97
+ }
98
+ }
99
+ return {
100
+ state: 'complete',
101
+ privosUrl: input.privosUrl,
102
+ clientId: input.clientId,
103
+ clientSecret: input.clientSecret,
104
+ mcpAppId: input.mcpAppId,
105
+ pairingVersion: 2,
106
+ trust,
107
+ fingerprint,
108
+ manifestDigest: trust.affinity.manifestDigest,
109
+ ...(approvedPermissionCeiling ? { approvedPermissionCeiling } : {}),
110
+ ...(identityFilePath ? { identityFilePath } : {}),
111
+ };
112
+ }
14
113
  /**
15
114
  * Pair with Privos using a one-time pairing URL.
16
115
  *
@@ -21,6 +120,13 @@ const DEFAULT_OPEN_HANDSHAKE_TIMEOUT_MS = 15_000;
21
120
  * create — same discipline as the Hub's own identity file), then prints the
22
121
  * Hub fingerprint for out-of-band operator verification. Set
23
122
  * `persistIdentityFile: false` to opt out and handle persistence yourself.
123
+ *
124
+ * If the socket closes after the Hub has persisted registration but before
125
+ * this function receives the result, the caller may explicitly invoke this
126
+ * function again with the same pairing URL and byte-semantically identical
127
+ * published manifest during the Hub's bounded recovery window. There is no
128
+ * automatic retry or polling loop: a changed URL, manifest, or authority must
129
+ * fail at the Hub rather than being hidden by client-side retry behavior.
24
130
  */
25
131
  export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, options) {
26
132
  const timeoutMs = options?.timeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS;
@@ -57,26 +163,98 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
57
163
  });
58
164
  }, timeoutMs);
59
165
  async function finishPairing(input) {
60
- if (input.pairingVersion !== 2) {
61
- // Either a v1 Hub, or a manifest-announced registration still awaiting
62
- // approval: both hand back credentials and nothing to persist, because
166
+ if (input.wire.pairingVersion !== 2) {
167
+ // Either a v1 Hub, or a Hub generation that acknowledges a
168
+ // manifest-announced registration with only `awaitingApproval`:
169
+ // both hand back credentials and nothing to persist, because
63
170
  // trust is minted with the generation an approved ceiling creates.
64
171
  return {
172
+ state: 'legacy-complete',
173
+ privosUrl: input.privosUrl,
174
+ clientId: input.clientId,
175
+ clientSecret: input.clientSecret,
176
+ mcpAppId: input.mcpAppId,
177
+ ...(input.wire.awaitingApproval ? { awaitingApproval: true } : {}),
178
+ };
179
+ }
180
+ if (!input.mcpAppId)
181
+ throw new Error('Pairing v2 response missing app identity.');
182
+ if (input.wire.pairingState === 'pending-approval') {
183
+ if (!appMeta.manifest)
184
+ throw new Error('Pending manifest pairing response was not requested with an exact manifest.');
185
+ const declaredPermissionCeiling = sortedUniqueStrings(input.wire.declaredPermissionCeiling, 'declaredPermissionCeiling');
186
+ const localDeclaredPermissionCeiling = manifestDeclaredPermissionCeiling(appMeta.manifest);
187
+ const manifestDigest = sha256CanonicalJson(appMeta.manifest);
188
+ if (input.wire.manifestDigest !== manifestDigest ||
189
+ JSON.stringify(declaredPermissionCeiling) !== JSON.stringify(localDeclaredPermissionCeiling) ||
190
+ !input.wire.pairingId ||
191
+ !input.wire.oauthClientId ||
192
+ input.wire.oauthClientId !== input.clientId ||
193
+ !input.wire.permissionContractHash ||
194
+ !input.wire.hubKid) {
195
+ throw new Error('Pending pairing response does not match the exact published manifest or OAuth identity.');
196
+ }
197
+ const fingerprint = standaloneHubFingerprint(input.wire.hubKid);
198
+ if (input.wire.fingerprint !== fingerprint)
199
+ throw new Error('Pending pairing Hub fingerprint is invalid.');
200
+ (options?.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before approving this app.`);
201
+ const pending = {
202
+ pairingVersion: 2,
203
+ state: 'pending-approval',
204
+ relayUrl: input.privosUrl,
205
+ clientId: input.clientId,
206
+ clientSecret: input.clientSecret,
207
+ mcpAppId: input.mcpAppId,
208
+ pairingId: input.wire.pairingId,
209
+ oauthClientId: input.wire.oauthClientId,
210
+ manifestDigest,
211
+ permissionContractHash: input.wire.permissionContractHash,
212
+ declaredPermissionCeiling,
213
+ hubKid: input.wire.hubKid,
214
+ fingerprint,
215
+ createdAt: Date.now(),
216
+ };
217
+ const pendingIdentityFilePath = persistIdentityFile
218
+ ? await saveStandalonePendingIdentity(pending, {
219
+ filePath: options?.pendingIdentityFilePath,
220
+ finalIdentityFilePath: options?.identityFilePath,
221
+ })
222
+ : undefined;
223
+ return {
224
+ state: 'pending-approval',
65
225
  privosUrl: input.privosUrl,
66
226
  clientId: input.clientId,
67
227
  clientSecret: input.clientSecret,
68
228
  mcpAppId: input.mcpAppId,
69
- ...(input.awaitingApproval ? { awaitingApproval: true } : {}),
229
+ pairingVersion: 2,
230
+ pairingId: pending.pairingId,
231
+ oauthClientId: pending.oauthClientId,
232
+ manifestDigest,
233
+ permissionContractHash: pending.permissionContractHash,
234
+ declaredPermissionCeiling,
235
+ hubKid: pending.hubKid,
236
+ fingerprint,
237
+ awaitingApproval: true,
238
+ ...(pendingIdentityFilePath ? { pendingIdentityFilePath } : {}),
70
239
  };
71
240
  }
72
- return persistPairedV2Identity(input, {
73
- onFingerprint: options?.onFingerprint,
74
- persistIdentityFile,
75
- identityFilePath: options?.identityFilePath,
241
+ return persistCompletedPairing({
242
+ wire: input.wire,
243
+ privosUrl: input.privosUrl,
244
+ clientId: input.clientId,
245
+ clientSecret: input.clientSecret,
246
+ mcpAppId: input.mcpAppId,
247
+ options,
76
248
  });
77
249
  }
78
250
  ws.on('open', () => {
79
251
  try {
252
+ if (appMeta.manifest) {
253
+ const lint = lintManifest(appMeta.manifest);
254
+ if (appMeta.manifest.schemaVersion !== 3 || !lint.valid) {
255
+ throw new Error(`Exact standalone manifest is not a valid schema-v3 manifest: ${lint.errors.join('; ')}`);
256
+ }
257
+ }
80
258
  ws.send(JSON.stringify({
81
259
  name: appMeta.name,
82
260
  description: appMeta.description || '',
@@ -84,7 +262,7 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
84
262
  ...(appMeta.icon && { icon: appMeta.icon }),
85
263
  ...(appMeta.scopes?.length && { scopes: appMeta.scopes }),
86
264
  ...(appMeta.permissions?.length && { permissions: appMeta.permissions }),
87
- ...(appMeta.manifest !== undefined && { manifest: appMeta.manifest }),
265
+ ...(appMeta.manifest ? { manifest: appMeta.manifest } : {}),
88
266
  }));
89
267
  }
90
268
  catch (err) {
@@ -98,8 +276,8 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
98
276
  settle(() => reject(new Error(msg.error?.message || 'Pairing failed')));
99
277
  return;
100
278
  }
101
- if (msg.result?.paired) {
102
- const { clientId, clientSecret, relayUrl, app, mcpAppId, appId, pairingVersion, trust, fingerprint, awaitingApproval } = msg.result;
279
+ if (msg.result && (msg.result.paired || msg.result.pairingState === 'pending-approval')) {
280
+ const { clientId, clientSecret, relayUrl, app, mcpAppId, appId } = msg.result;
103
281
  if (!clientId || !clientSecret || !relayUrl) {
104
282
  settle(() => reject(new Error('Pairing response missing credentials')));
105
283
  return;
@@ -111,14 +289,11 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
111
289
  undefined;
112
290
  settle(() => {
113
291
  void finishPairing({
292
+ wire: msg.result,
114
293
  privosUrl,
115
294
  clientId,
116
295
  clientSecret,
117
296
  mcpAppId: resolvedAppId,
118
- pairingVersion,
119
- trust,
120
- fingerprint,
121
- awaitingApproval: awaitingApproval === true,
122
297
  })
123
298
  .then(resolve)
124
299
  .catch(reject);
@@ -152,40 +327,6 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
152
327
  });
153
328
  }
154
329
  const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60_000;
155
- /** Validate a v2 trust payload, verify the fingerprint, and persist the identity file. */
156
- async function persistPairedV2Identity(input, options) {
157
- assertRuntimeDispatchTrustConfigurationV3(input.trust);
158
- const trust = input.trust;
159
- const fingerprint = standaloneHubFingerprint(trust.hubKid);
160
- if (input.fingerprint !== undefined && input.fingerprint !== fingerprint) {
161
- throw new Error('Pairing response fingerprint does not match the pinned Hub key.');
162
- }
163
- (options.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before trusting dispatch from this Hub.`);
164
- let identityFilePath;
165
- if (options.persistIdentityFile) {
166
- const identity = {
167
- pairingVersion: 2,
168
- relayUrl: input.privosUrl,
169
- clientId: input.clientId,
170
- clientSecret: input.clientSecret,
171
- trust,
172
- fingerprint,
173
- ...(input.mcpAppId ? { mcpAppId: input.mcpAppId } : {}),
174
- pairedAt: Date.now(),
175
- };
176
- identityFilePath = await saveStandaloneIdentity(identity, { filePath: options.identityFilePath });
177
- }
178
- return {
179
- privosUrl: input.privosUrl,
180
- clientId: input.clientId,
181
- clientSecret: input.clientSecret,
182
- mcpAppId: input.mcpAppId,
183
- pairingVersion: 2,
184
- trust,
185
- fingerprint,
186
- ...(identityFilePath ? { identityFilePath } : {}),
187
- };
188
- }
189
330
  /**
190
331
  * Normalize a Hub-supplied relay URL (`wss://host/api/v1/mcp-apps.relay`) to the
191
332
  * bare Hub HTTP origin the identity file stores as `relayUrl`/`privosUrl`. serveApp
@@ -206,8 +347,9 @@ function pairPollTarget(pairUrl) {
206
347
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
207
348
  /**
208
349
  * One-command pairing (device-authorization flow). Registers exactly like
209
- * {@link pairOverWebSocket}; if the Hub answers `awaitingApproval`, this then
210
- * POLLS the Hub with the SAME pairing token until an admin approves the
350
+ * {@link pairOverWebSocket}; while the registration awaits an admin's approval
351
+ * (`pending-approval` state, or a legacy-shaped `awaitingApproval` response),
352
+ * this POLLS the Hub with the SAME pairing token until the admin approves the
211
353
  * permission ceiling, receives the pairing-v2 trust payload, and writes the
212
354
  * standalone identity file — no second pairing URL. A Hub that returns trust
213
355
  * immediately (already-approved linked pairing, or a v1 Hub) is passed straight
@@ -216,8 +358,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
216
358
  */
217
359
  export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = WebSocket, options) {
218
360
  const registered = await pairOverWebSocket(pairUrl, appMeta, WebSocketImpl, options);
219
- // A Hub that already handed trust (linked pairing) or a v1 Hub: nothing to await.
220
- if (registered.pairingVersion === 2 || !registered.awaitingApproval)
361
+ // Trust already delivered (linked pairing), or a v1 Hub with nothing pending: done.
362
+ if (registered.state === 'complete' || !registered.awaitingApproval)
221
363
  return registered;
222
364
  const { origin, pairToken } = pairPollTarget(pairUrl);
223
365
  if (!pairToken)
@@ -237,14 +379,36 @@ export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = Web
237
379
  if (body.success === false)
238
380
  throw new Error(body.error || 'Pairing poll was rejected');
239
381
  if (body.status === 'approved') {
240
- return persistPairedV2Identity({
382
+ const mcpAppId = body.appId ?? registered.mcpAppId;
383
+ if (!mcpAppId)
384
+ throw new Error('Pairing approval response missing app identity.');
385
+ const completed = await persistCompletedPairing({
386
+ wire: { trust: body.trust, fingerprint: body.fingerprint },
241
387
  privosUrl: body.relayUrl ? relayUrlToPrivosOrigin(body.relayUrl) : registered.privosUrl,
242
388
  clientId: body.clientId ?? registered.clientId,
243
389
  clientSecret: body.clientSecret ?? registered.clientSecret,
244
- mcpAppId: body.appId ?? registered.mcpAppId,
245
- trust: body.trust,
246
- fingerprint: body.fingerprint,
247
- }, { onFingerprint: options?.onFingerprint, persistIdentityFile, identityFilePath: options?.identityFilePath });
390
+ mcpAppId,
391
+ options,
392
+ });
393
+ // The pair-poll response carries no pending-contract echo to cross-check,
394
+ // so the durable pending half is consumed only after trust verification
395
+ // above — best-effort, the completed identity file is authoritative.
396
+ if (registered.state === 'pending-approval' && persistIdentityFile) {
397
+ try {
398
+ const stalePending = loadStandalonePendingIdentity({
399
+ filePath: options?.pendingIdentityFilePath,
400
+ finalIdentityFilePath: options?.identityFilePath,
401
+ });
402
+ await consumeStandalonePendingIdentity(stalePending.identity, {
403
+ filePath: options?.pendingIdentityFilePath,
404
+ finalIdentityFilePath: options?.identityFilePath,
405
+ });
406
+ }
407
+ catch {
408
+ /* pending file already gone — nothing left to consume */
409
+ }
410
+ }
411
+ return completed;
248
412
  }
249
413
  if (body.status === 'rejected')
250
414
  throw new Error('Pairing was rejected — the app was removed. Re-pair from scratch.');
@@ -257,6 +421,125 @@ export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = Web
257
421
  delayMs = Math.min(Math.round(delayMs * 1.5), 10_000);
258
422
  }
259
423
  }
424
+ /**
425
+ * Explicitly resumes one durable app-announced-manifest pairing. This is a
426
+ * single operator/app action, not a polling loop: a pending Hub replies
427
+ * pending; an approved Hub returns trust for the same OAuth/app/pairing
428
+ * identity and the SDK atomically promotes it to the production identity.
429
+ */
430
+ export async function resumeStandalonePairing(options) {
431
+ const loaded = loadStandalonePendingIdentity({
432
+ filePath: options?.pendingIdentityFilePath,
433
+ finalIdentityFilePath: options?.identityFilePath,
434
+ });
435
+ const pending = loaded.identity;
436
+ const fetchImpl = options?.fetchImpl ?? fetch;
437
+ const WebSocketImpl = options?.WebSocketImpl ?? WebSocket;
438
+ const controller = new AbortController();
439
+ const oauthTimer = setTimeout(() => controller.abort(), options?.oauthTimeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS);
440
+ let accessToken;
441
+ try {
442
+ const response = await fetchImpl(`${pending.relayUrl}/oauth/token`, {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
445
+ body: `grant_type=client_credentials&client_id=${encodeURIComponent(pending.clientId)}&client_secret=${encodeURIComponent(pending.clientSecret)}`,
446
+ signal: controller.signal,
447
+ });
448
+ if (!response.ok)
449
+ throw new Error(`Pairing completion OAuth failed: ${response.status} ${response.statusText}`);
450
+ const token = (await response.json());
451
+ if (!token.access_token)
452
+ throw new Error('Pairing completion OAuth response omitted access_token.');
453
+ accessToken = token.access_token;
454
+ }
455
+ finally {
456
+ clearTimeout(oauthTimer);
457
+ }
458
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS;
459
+ const wire = await new Promise((resolve, reject) => {
460
+ const wsUrl = `${pending.relayUrl.replace(/^http/, 'ws')}/api/v1/mcp-apps.relay?pairingCompletion=1`;
461
+ const ws = new WebSocketImpl(wsUrl, { headers: { Authorization: `Bearer ${accessToken}` } });
462
+ let settled = false;
463
+ const settle = (fn) => {
464
+ if (settled)
465
+ return;
466
+ settled = true;
467
+ clearTimeout(timer);
468
+ fn();
469
+ };
470
+ const timer = setTimeout(() => {
471
+ settle(() => reject(new Error(`Pairing completion timed out after ${timeoutMs}ms`)));
472
+ try {
473
+ ws.close();
474
+ }
475
+ catch { /* ignore */ }
476
+ }, timeoutMs);
477
+ ws.on('message', (raw) => {
478
+ try {
479
+ const message = JSON.parse(rawDataToText(raw));
480
+ if (message.error)
481
+ return settle(() => reject(new Error(message.error?.message || 'Pairing completion failed')));
482
+ if (message.result?.pairingVersion !== 2)
483
+ return settle(() => reject(new Error('Pairing completion response is not protocol v2.')));
484
+ settle(() => resolve(message.result));
485
+ try {
486
+ ws.close();
487
+ }
488
+ catch { /* ignore */ }
489
+ }
490
+ catch (error) {
491
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
492
+ }
493
+ });
494
+ ws.on('error', (error) => settle(() => reject(new Error(`Pairing completion failed: ${error.message}`))));
495
+ ws.on('close', (code, reason) => settle(() => reject(new Error(`Pairing completion closed: ${code} ${reason}`))));
496
+ });
497
+ if (wire.clientId !== pending.clientId ||
498
+ wire.oauthClientId !== pending.oauthClientId ||
499
+ wire.appId !== pending.mcpAppId ||
500
+ wire.pairingId !== pending.pairingId ||
501
+ wire.manifestDigest !== pending.manifestDigest ||
502
+ wire.permissionContractHash !== pending.permissionContractHash ||
503
+ wire.hubKid !== pending.hubKid ||
504
+ wire.fingerprint !== pending.fingerprint) {
505
+ throw new Error('Pairing resume response does not match the durable pending identity.');
506
+ }
507
+ if (wire.pairingState === 'pending-approval') {
508
+ const declaredPermissionCeiling = sortedUniqueStrings(wire.declaredPermissionCeiling, 'declaredPermissionCeiling');
509
+ if (JSON.stringify(declaredPermissionCeiling) !== JSON.stringify(pending.declaredPermissionCeiling)) {
510
+ throw new Error('Pairing resume changed the declared permission ceiling.');
511
+ }
512
+ return {
513
+ state: 'pending-approval',
514
+ privosUrl: pending.relayUrl,
515
+ clientId: pending.clientId,
516
+ clientSecret: pending.clientSecret,
517
+ mcpAppId: pending.mcpAppId,
518
+ pairingVersion: 2,
519
+ pairingId: pending.pairingId,
520
+ oauthClientId: pending.oauthClientId,
521
+ manifestDigest: pending.manifestDigest,
522
+ permissionContractHash: pending.permissionContractHash,
523
+ declaredPermissionCeiling: pending.declaredPermissionCeiling,
524
+ hubKid: pending.hubKid,
525
+ fingerprint: pending.fingerprint,
526
+ awaitingApproval: true,
527
+ pendingIdentityFilePath: loaded.filePath,
528
+ };
529
+ }
530
+ if (wire.pairingState !== 'complete' || !wire.clientSecret || wire.clientSecret !== pending.clientSecret) {
531
+ throw new Error('Pairing resume did not return an exact completed identity.');
532
+ }
533
+ return persistCompletedPairing({
534
+ wire,
535
+ privosUrl: pending.relayUrl,
536
+ clientId: pending.clientId,
537
+ clientSecret: pending.clientSecret,
538
+ mcpAppId: pending.mcpAppId,
539
+ options,
540
+ pending,
541
+ });
542
+ }
260
543
  export async function pairFromDescriptor(pairUrl, descriptor, WebSocketImpl, options) {
261
544
  return pairOverWebSocket(pairUrl, buildPairingMetadata(descriptor), WebSocketImpl, options);
262
545
  }
@@ -573,21 +856,45 @@ export function connectRelay(opts) {
573
856
  const transportMsgObj = parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)
574
857
  ? parsedJson
575
858
  : {};
859
+ const controlRequestId = Object.prototype.hasOwnProperty.call(transportMsgObj, 'id') &&
860
+ (typeof transportMsgObj.id === 'string' || typeof transportMsgObj.id === 'number' || transportMsgObj.id === null)
861
+ ? transportMsgObj.id
862
+ : undefined;
576
863
  // Standalone-production control channel: secret rotation, trust rotation,
577
864
  // and capabilities push arrive as reserved notifications on this same
578
865
  // authenticated connection. They are never MCP dispatch and are handled
579
866
  // (verified against the currently pinned Hub key) before anything else.
580
867
  if (opts.standaloneIdentity && isStandaloneControlMethod(transportMsgObj.method)) {
581
868
  try {
582
- await opts.standaloneIdentity.handleControlNotification(transportMsgObj.method, transportMsgObj.params);
869
+ const outcome = await opts.standaloneIdentity.handleControlNotification(transportMsgObj.method, transportMsgObj.params);
870
+ if (transportMsgObj.method === STANDALONE_AGENT_BOT_CREDENTIAL_METHOD && controlRequestId !== undefined) {
871
+ if (typeof outcome === 'string') {
872
+ safeSend(ws, errorResponse(controlRequestId, jsonRpcError(INVALID_REQUEST, 'Standalone credential delivery rejected')), {
873
+ generation,
874
+ method: transportMsgObj.method,
875
+ });
876
+ }
877
+ else {
878
+ safeSend(ws, { jsonrpc: '2.0', id: controlRequestId, result: outcome }, {
879
+ generation,
880
+ method: transportMsgObj.method,
881
+ });
882
+ }
883
+ }
583
884
  log('relay.standalone_control.applied', { generation, method: transportMsgObj.method });
584
885
  }
585
886
  catch (err) {
586
887
  log('relay.standalone_control.rejected', {
587
888
  generation,
588
889
  method: transportMsgObj.method,
589
- message: err instanceof Error ? err.message : String(err),
890
+ ...(err instanceof Error ? { name: err.name } : {}),
590
891
  });
892
+ if (transportMsgObj.method === STANDALONE_AGENT_BOT_CREDENTIAL_METHOD && controlRequestId !== undefined) {
893
+ safeSend(ws, errorResponse(controlRequestId, jsonRpcError(INVALID_REQUEST, 'Standalone credential delivery rejected')), {
894
+ generation,
895
+ method: transportMsgObj.method,
896
+ });
897
+ }
591
898
  }
592
899
  return;
593
900
  }