@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.
- package/README.md +39 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/relay/relay-client.d.ts +80 -20
- package/dist/relay/relay-client.d.ts.map +1 -1
- package/dist/relay/relay-client.js +369 -62
- package/dist/relay/relay-client.js.map +1 -1
- package/dist/relay/standalone-control.d.ts +7 -1
- package/dist/relay/standalone-control.d.ts.map +1 -1
- package/dist/relay/standalone-control.js +47 -5
- package/dist/relay/standalone-control.js.map +1 -1
- package/dist/relay/standalone-identity.d.ts +45 -0
- package/dist/relay/standalone-identity.d.ts.map +1 -1
- package/dist/relay/standalone-identity.js +124 -2
- package/dist/relay/standalone-identity.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
62
|
-
//
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
102
|
-
const { clientId, clientSecret, relayUrl, app, mcpAppId, appId
|
|
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};
|
|
210
|
-
*
|
|
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
|
-
//
|
|
220
|
-
if (registered.
|
|
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
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
}
|