@sellable/mcp 0.1.619-wip.121.2 → 0.1.619
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/dist/agent-approval-effect-port.d.ts +5 -0
- package/dist/agent-approval-effect-port.js +30 -0
- package/dist/agent-service-auth.d.ts +94 -0
- package/dist/agent-service-auth.js +177 -0
- package/dist/agent-tool-policy.d.ts +156 -0
- package/dist/agent-tool-policy.js +358 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +3 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +121 -3
- package/dist/server.js +96 -9
- package/dist/tools/company-info.d.ts +7 -1
- package/dist/tools/company-info.js +8 -2
- package/dist/tools/registry.d.ts +39 -39
- package/package.json +2 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
export const DEFAULT_AGENT_TOOL_CATALOGUE = [
|
|
2
|
+
{
|
|
3
|
+
tool: "get_company_info",
|
|
4
|
+
version: "v1",
|
|
5
|
+
decision: "AUTO",
|
|
6
|
+
method: "GET",
|
|
7
|
+
route: "/api/v3/reply-management/company-info",
|
|
8
|
+
handler: "getCompanyInfoMarkdown",
|
|
9
|
+
workspaceBinding: "workspaceId",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
tool: "set_company_info",
|
|
13
|
+
version: "v1",
|
|
14
|
+
decision: "REQUESTER_CONFIRM",
|
|
15
|
+
method: "PUT",
|
|
16
|
+
route: "/api/v3/reply-management/company-info",
|
|
17
|
+
handler: "saveCompanyInfoMarkdown",
|
|
18
|
+
workspaceBinding: "workspaceId",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
export function compileAgentToolPolicy(entries = DEFAULT_AGENT_TOOL_CATALOGUE) {
|
|
22
|
+
const compiledEntries = [...entries]
|
|
23
|
+
.map((entry) => ({ ...entry }))
|
|
24
|
+
.sort((left, right) => left.tool.localeCompare(right.tool));
|
|
25
|
+
const toolNames = new Set(compiledEntries.map((entry) => entry.tool));
|
|
26
|
+
if (toolNames.size !== compiledEntries.length) {
|
|
27
|
+
throw new Error("Agent tool policy contains duplicate tool names");
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
policyHash: portableSha256(JSON.stringify(canonicalize(compiledEntries))),
|
|
31
|
+
entries: Object.freeze(compiledEntries.map((entry) => Object.freeze(entry))),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function decideAgentTool(compiled, tool) {
|
|
35
|
+
const entry = compiled.entries.find((candidate) => candidate.tool === tool);
|
|
36
|
+
if (entry)
|
|
37
|
+
return { ...entry, allowed: entry.decision !== "DENY" };
|
|
38
|
+
return {
|
|
39
|
+
tool,
|
|
40
|
+
version: "unknown",
|
|
41
|
+
decision: "DENY",
|
|
42
|
+
method: "GET",
|
|
43
|
+
route: "",
|
|
44
|
+
handler: "",
|
|
45
|
+
workspaceBinding: "",
|
|
46
|
+
allowed: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function listToolsForAgent(compiled, definitions) {
|
|
50
|
+
const allowed = new Set(compiled.entries
|
|
51
|
+
.filter((entry) => entry.decision !== "DENY")
|
|
52
|
+
.map((entry) => entry.tool));
|
|
53
|
+
return definitions.filter((definition) => allowed.has(definition.name));
|
|
54
|
+
}
|
|
55
|
+
function canonicalize(value) {
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
return value.map(canonicalize);
|
|
58
|
+
if (value && typeof value === "object") {
|
|
59
|
+
return Object.fromEntries(Object.entries(value)
|
|
60
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
61
|
+
.map(([key, item]) => [key, canonicalize(item)]));
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
const SHA256_CONSTANTS = [
|
|
66
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b,
|
|
67
|
+
0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
|
|
68
|
+
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7,
|
|
69
|
+
0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
|
70
|
+
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152,
|
|
71
|
+
0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
|
72
|
+
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
|
|
73
|
+
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
74
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
|
75
|
+
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
|
|
76
|
+
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
|
|
77
|
+
0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
78
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
79
|
+
];
|
|
80
|
+
function rotateRight(value, bits) {
|
|
81
|
+
return (value >>> bits) | (value << (32 - bits));
|
|
82
|
+
}
|
|
83
|
+
export function portableSha256(value) {
|
|
84
|
+
const bytes = new TextEncoder().encode(value);
|
|
85
|
+
const bitLength = bytes.length * 8;
|
|
86
|
+
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
87
|
+
const padded = new Uint8Array(paddedLength);
|
|
88
|
+
padded.set(bytes);
|
|
89
|
+
padded[bytes.length] = 0x80;
|
|
90
|
+
const view = new DataView(padded.buffer);
|
|
91
|
+
view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000));
|
|
92
|
+
view.setUint32(paddedLength - 4, bitLength >>> 0);
|
|
93
|
+
const hash = [
|
|
94
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f,
|
|
95
|
+
0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
96
|
+
];
|
|
97
|
+
const words = new Uint32Array(64);
|
|
98
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
99
|
+
for (let index = 0; index < 16; index += 1) {
|
|
100
|
+
words[index] = view.getUint32(offset + index * 4);
|
|
101
|
+
}
|
|
102
|
+
for (let index = 16; index < 64; index += 1) {
|
|
103
|
+
const left = words[index - 15];
|
|
104
|
+
const right = words[index - 2];
|
|
105
|
+
const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
|
|
106
|
+
const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
|
|
107
|
+
words[index] =
|
|
108
|
+
(words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
|
|
109
|
+
}
|
|
110
|
+
let [a, b, c, d, e, f, g, h] = hash;
|
|
111
|
+
for (let index = 0; index < 64; index += 1) {
|
|
112
|
+
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
|
113
|
+
const choice = (e & f) ^ (~e & g);
|
|
114
|
+
const temp1 = (h + sum1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
|
|
115
|
+
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
|
116
|
+
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
117
|
+
const temp2 = (sum0 + majority) >>> 0;
|
|
118
|
+
h = g;
|
|
119
|
+
g = f;
|
|
120
|
+
f = e;
|
|
121
|
+
e = (d + temp1) >>> 0;
|
|
122
|
+
d = c;
|
|
123
|
+
c = b;
|
|
124
|
+
b = a;
|
|
125
|
+
a = (temp1 + temp2) >>> 0;
|
|
126
|
+
}
|
|
127
|
+
hash[0] = (hash[0] + a) >>> 0;
|
|
128
|
+
hash[1] = (hash[1] + b) >>> 0;
|
|
129
|
+
hash[2] = (hash[2] + c) >>> 0;
|
|
130
|
+
hash[3] = (hash[3] + d) >>> 0;
|
|
131
|
+
hash[4] = (hash[4] + e) >>> 0;
|
|
132
|
+
hash[5] = (hash[5] + f) >>> 0;
|
|
133
|
+
hash[6] = (hash[6] + g) >>> 0;
|
|
134
|
+
hash[7] = (hash[7] + h) >>> 0;
|
|
135
|
+
}
|
|
136
|
+
return hash.map((word) => word.toString(16).padStart(8, "0")).join("");
|
|
137
|
+
}
|
|
138
|
+
export function hashApprovalArguments(args) {
|
|
139
|
+
return portableSha256(JSON.stringify(canonicalize(args)));
|
|
140
|
+
}
|
|
141
|
+
export function canonicalAgentToolArguments(tool, args) {
|
|
142
|
+
if (tool !== "set_company_info" || !args || typeof args !== "object") {
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
const input = args;
|
|
146
|
+
const markdown = typeof input.companyInfoMarkdown === "string"
|
|
147
|
+
? input.companyInfoMarkdown.trim()
|
|
148
|
+
? input.companyInfoMarkdown
|
|
149
|
+
: null
|
|
150
|
+
: input.companyInfoMarkdown === null
|
|
151
|
+
? null
|
|
152
|
+
: null;
|
|
153
|
+
return input.forceOverwrite === true
|
|
154
|
+
? { companyInfoMarkdown: markdown, forceOverwrite: true }
|
|
155
|
+
: {
|
|
156
|
+
companyInfoMarkdown: markdown,
|
|
157
|
+
expectedUpdatedAt: typeof input.expectedUpdatedAt === "string" ||
|
|
158
|
+
input.expectedUpdatedAt === null
|
|
159
|
+
? input.expectedUpdatedAt
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function hashAgentToolArguments(tool, args) {
|
|
164
|
+
return hashApprovalArguments(canonicalAgentToolArguments(tool, args));
|
|
165
|
+
}
|
|
166
|
+
export function deriveAgentApprovalRequestId(input) {
|
|
167
|
+
return `saa_${hashApprovalArguments({
|
|
168
|
+
workspaceId: input.workspaceId,
|
|
169
|
+
agentId: input.agentId,
|
|
170
|
+
requesterId: input.requesterId,
|
|
171
|
+
channelId: input.channelId,
|
|
172
|
+
providerRequestId: input.providerRequestId,
|
|
173
|
+
tool: input.tool,
|
|
174
|
+
toolVersion: input.toolVersion,
|
|
175
|
+
argumentHash: hashAgentToolArguments(input.tool, input.arguments),
|
|
176
|
+
policyHash: input.policyHash,
|
|
177
|
+
credentialGeneration: input.credentialGeneration,
|
|
178
|
+
decision: input.decision,
|
|
179
|
+
})}`;
|
|
180
|
+
}
|
|
181
|
+
export function validateApprovalBinding(input) {
|
|
182
|
+
const { approval, expected } = input;
|
|
183
|
+
if (!approval)
|
|
184
|
+
return { ok: false, code: "approval_required" };
|
|
185
|
+
if (approval.consumedAt)
|
|
186
|
+
return { ok: false, code: "approval_consumed" };
|
|
187
|
+
if (approval.revokedAt)
|
|
188
|
+
return { ok: false, code: "approval_revoked" };
|
|
189
|
+
if (approval.expiresAt.getTime() <= (input.now ?? new Date()).getTime()) {
|
|
190
|
+
return { ok: false, code: "approval_expired" };
|
|
191
|
+
}
|
|
192
|
+
for (const key of Object.keys(expected)) {
|
|
193
|
+
if (approval[key] !== expected[key]) {
|
|
194
|
+
return { ok: false, code: `approval_${String(key)}_mismatch` };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, code: "approved" };
|
|
198
|
+
}
|
|
199
|
+
export function createAgentMcpAuthorizationHandlers(input) {
|
|
200
|
+
const policy = input.policy ?? compileAgentToolPolicy();
|
|
201
|
+
return {
|
|
202
|
+
listTools(context) {
|
|
203
|
+
if (!context.principal)
|
|
204
|
+
return input.allTools;
|
|
205
|
+
if (context.principal.policyHash !== policy.policyHash ||
|
|
206
|
+
!Number.isSafeInteger(context.principal.generation) ||
|
|
207
|
+
context.principal.generation < 1) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
return listToolsForAgent(policy, input.allTools);
|
|
211
|
+
},
|
|
212
|
+
async authorizeCall(call) {
|
|
213
|
+
const principal = call.context.principal;
|
|
214
|
+
if (!principal) {
|
|
215
|
+
return { allowed: false, decision: "DENY", code: "agent_principal_required" };
|
|
216
|
+
}
|
|
217
|
+
if (principal.policyHash !== policy.policyHash) {
|
|
218
|
+
return { allowed: false, decision: "DENY", code: "policy_hash_mismatch" };
|
|
219
|
+
}
|
|
220
|
+
if (!Number.isSafeInteger(principal.generation) || principal.generation < 1) {
|
|
221
|
+
return { allowed: false, decision: "DENY", code: "credential_generation_invalid" };
|
|
222
|
+
}
|
|
223
|
+
const decision = decideAgentTool(policy, call.name);
|
|
224
|
+
if (!decision.allowed) {
|
|
225
|
+
return { allowed: false, decision: "DENY", code: "tool_denied" };
|
|
226
|
+
}
|
|
227
|
+
const policyEntry = {
|
|
228
|
+
tool: decision.tool,
|
|
229
|
+
version: decision.version,
|
|
230
|
+
decision: decision.decision,
|
|
231
|
+
method: decision.method,
|
|
232
|
+
route: decision.route,
|
|
233
|
+
handler: decision.handler,
|
|
234
|
+
workspaceBinding: decision.workspaceBinding,
|
|
235
|
+
};
|
|
236
|
+
if (decision.decision === "AUTO") {
|
|
237
|
+
return { allowed: true, decision: "AUTO", policy: policyEntry };
|
|
238
|
+
}
|
|
239
|
+
if (decision.decision !== "REQUESTER_CONFIRM" &&
|
|
240
|
+
decision.decision !== "WORKSPACE_APPROVER") {
|
|
241
|
+
return { allowed: false, decision: "DENY", code: "tool_denied" };
|
|
242
|
+
}
|
|
243
|
+
const approvalDecision = decision.decision;
|
|
244
|
+
if (!input.approvalPort) {
|
|
245
|
+
return {
|
|
246
|
+
allowed: false,
|
|
247
|
+
decision: approvalDecision,
|
|
248
|
+
code: "approval_port_unavailable",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const requesterId = call.context.requesterId?.trim();
|
|
252
|
+
const channelId = call.context.channelId?.trim();
|
|
253
|
+
const providerRequestId = call.context.providerRequestId?.trim();
|
|
254
|
+
const approvalId = call.context.approvalId?.trim();
|
|
255
|
+
if (!requesterId || !channelId || !providerRequestId) {
|
|
256
|
+
return {
|
|
257
|
+
allowed: false,
|
|
258
|
+
decision: approvalDecision,
|
|
259
|
+
code: "approval_context_required",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const expectedApprovalId = deriveAgentApprovalRequestId({
|
|
263
|
+
workspaceId: principal.workspaceId,
|
|
264
|
+
agentId: principal.agentId,
|
|
265
|
+
requesterId,
|
|
266
|
+
channelId,
|
|
267
|
+
providerRequestId,
|
|
268
|
+
tool: decision.tool,
|
|
269
|
+
toolVersion: decision.version,
|
|
270
|
+
arguments: call.args,
|
|
271
|
+
policyHash: principal.policyHash,
|
|
272
|
+
credentialGeneration: principal.generation,
|
|
273
|
+
decision: approvalDecision,
|
|
274
|
+
});
|
|
275
|
+
if (!approvalId) {
|
|
276
|
+
if (!input.approvalPort.requestApproval) {
|
|
277
|
+
return {
|
|
278
|
+
allowed: false,
|
|
279
|
+
decision: approvalDecision,
|
|
280
|
+
code: "approval_port_unavailable",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const approval = await input.approvalPort.requestApproval({
|
|
284
|
+
requesterId,
|
|
285
|
+
channelId,
|
|
286
|
+
providerRequestId,
|
|
287
|
+
tool: decision.tool,
|
|
288
|
+
arguments: call.args,
|
|
289
|
+
});
|
|
290
|
+
if (approval.approvalId !== expectedApprovalId ||
|
|
291
|
+
approval.workspaceId !== principal.workspaceId ||
|
|
292
|
+
approval.agentId !== principal.agentId ||
|
|
293
|
+
approval.requesterId !== requesterId ||
|
|
294
|
+
approval.channelId !== channelId ||
|
|
295
|
+
approval.providerRequestId !== providerRequestId ||
|
|
296
|
+
approval.tool !== decision.tool ||
|
|
297
|
+
approval.toolVersion !== decision.version ||
|
|
298
|
+
approval.decision !== approvalDecision) {
|
|
299
|
+
return {
|
|
300
|
+
allowed: false,
|
|
301
|
+
decision: approvalDecision,
|
|
302
|
+
code: "approval_mismatch",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
allowed: false,
|
|
307
|
+
decision: approvalDecision,
|
|
308
|
+
code: "approval_required",
|
|
309
|
+
approval,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (approvalId !== expectedApprovalId) {
|
|
313
|
+
return {
|
|
314
|
+
allowed: false,
|
|
315
|
+
decision: approvalDecision,
|
|
316
|
+
code: "approval_mismatch",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const consumed = await input.approvalPort.consumeApprovalAndStartEffect({
|
|
320
|
+
approvalId,
|
|
321
|
+
requesterId,
|
|
322
|
+
channelId,
|
|
323
|
+
providerRequestId,
|
|
324
|
+
argumentHash: hashAgentToolArguments(decision.tool, call.args),
|
|
325
|
+
workspaceId: principal.workspaceId,
|
|
326
|
+
agentId: principal.agentId,
|
|
327
|
+
tool: decision.tool,
|
|
328
|
+
toolVersion: decision.version,
|
|
329
|
+
policyHash: principal.policyHash,
|
|
330
|
+
credentialGeneration: principal.generation,
|
|
331
|
+
decision: approvalDecision,
|
|
332
|
+
now: input.now?.() ?? new Date(),
|
|
333
|
+
arguments: call.args,
|
|
334
|
+
});
|
|
335
|
+
if (consumed.status === "AUTHORIZED") {
|
|
336
|
+
return {
|
|
337
|
+
allowed: true,
|
|
338
|
+
decision: approvalDecision,
|
|
339
|
+
policy: policyEntry,
|
|
340
|
+
effectId: consumed.effectId,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const codeByStatus = {
|
|
344
|
+
MISSING: "approval_required",
|
|
345
|
+
MISMATCH: "approval_mismatch",
|
|
346
|
+
EXPIRED: "approval_expired",
|
|
347
|
+
REVOKED: "approval_revoked",
|
|
348
|
+
CONSUMED: "approval_consumed",
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
allowed: false,
|
|
352
|
+
decision: approvalDecision,
|
|
353
|
+
code: codeByStatus[consumed.status],
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
policy,
|
|
357
|
+
};
|
|
358
|
+
}
|
package/dist/api.d.ts
CHANGED
package/dist/api.js
CHANGED
|
@@ -54,6 +54,9 @@ export class SellableApi {
|
|
|
54
54
|
"Content-Type": "application/json",
|
|
55
55
|
Authorization: `Bearer ${config.token}`,
|
|
56
56
|
...(workspaceId ? { "x-workspaceid": workspaceId } : {}),
|
|
57
|
+
...(options?.agentEffectId
|
|
58
|
+
? { "x-sellable-agent-effect-id": options.agentEffectId }
|
|
59
|
+
: {}),
|
|
57
60
|
},
|
|
58
61
|
...(body && { body: JSON.stringify(body) }),
|
|
59
62
|
});
|
package/dist/auth.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface SellableMultiEnvConfig {
|
|
|
14
14
|
export type SellableConfig = SellableEnvConfig & {
|
|
15
15
|
/** Name of the active environment (e.g. "local", "prod"). */
|
|
16
16
|
activeEnvName?: string;
|
|
17
|
+
/** Credential class controls MCP discovery/invocation and API semantics. */
|
|
18
|
+
credentialKind?: "human" | "agent_service";
|
|
17
19
|
};
|
|
18
20
|
export type EngageWorkspaceState = {
|
|
19
21
|
lastSenderId?: string;
|
|
@@ -47,6 +49,8 @@ export declare function assertWorkspaceLockSatisfied(action?: string): void;
|
|
|
47
49
|
export declare function getEffectiveConfiguredWorkspaceId(config: Pick<SellableConfig, "activeWorkspaceId" | "workspaceId">): string | null;
|
|
48
50
|
export declare function resolveWorkspaceIdForRequest(requestedWorkspaceId: string | null | undefined, action?: string): string | null;
|
|
49
51
|
export declare function getConfig(): SellableConfig;
|
|
52
|
+
/** Reads only the credential class; never returns or validates credential bytes. */
|
|
53
|
+
export declare function getConfiguredCredentialIntent(): "unconfigured" | "human" | "agent_service" | "invalid";
|
|
50
54
|
export declare function updateActiveWorkspace(params: {
|
|
51
55
|
workspaceId: string;
|
|
52
56
|
workspaceName?: string | null;
|
package/dist/auth.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readMcpAgentServiceContext } from "./agent-service-auth.js";
|
|
4
6
|
const DEFAULT_API_URL = "https://app.sellable.dev";
|
|
5
7
|
const WORKSPACE_LOCK_ENV = "SELLABLE_LOCK_WORKSPACE_ID";
|
|
6
8
|
const REQUIRE_WORKSPACE_LOCK_ENV = "SELLABLE_REQUIRE_WORKSPACE_LOCK";
|
|
9
|
+
const AGENT_SERVICE_CREDENTIAL_FILE_ENV = "SELLABLE_AGENT_SERVICE_CREDENTIAL_FILE";
|
|
10
|
+
const AGENT_RUNTIME_SECRET_ROOT_ENV = "SELLABLE_AGENT_RUNTIME_SECRET_ROOT";
|
|
11
|
+
const AGENT_PROFILE_ID_ENV = "SELLABLE_AGENT_PROFILE_ID";
|
|
7
12
|
// No caching — re-read config on every call so token/workspace changes
|
|
8
13
|
// made by the LLM (editing sellable.json) take effect immediately
|
|
9
14
|
// without requiring an MCP server restart.
|
|
@@ -87,6 +92,64 @@ function normalizeConfig(raw) {
|
|
|
87
92
|
apiUrl: raw.apiUrl || DEFAULT_API_URL,
|
|
88
93
|
};
|
|
89
94
|
}
|
|
95
|
+
function readConfinedAgentServiceCredential(raw, env = process.env) {
|
|
96
|
+
const credentialPathValue = env[AGENT_SERVICE_CREDENTIAL_FILE_ENV]?.trim();
|
|
97
|
+
if (!credentialPathValue)
|
|
98
|
+
return null;
|
|
99
|
+
const runtimeRootValue = env[AGENT_RUNTIME_SECRET_ROOT_ENV]?.trim();
|
|
100
|
+
const profileId = env[AGENT_PROFILE_ID_ENV]?.trim();
|
|
101
|
+
if (!runtimeRootValue ||
|
|
102
|
+
!path.isAbsolute(runtimeRootValue) ||
|
|
103
|
+
!path.isAbsolute(credentialPathValue) ||
|
|
104
|
+
!profileId ||
|
|
105
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(profileId)) {
|
|
106
|
+
throw new Error("Invalid Agent service credential file binding");
|
|
107
|
+
}
|
|
108
|
+
const rootLink = fs.lstatSync(runtimeRootValue);
|
|
109
|
+
if (rootLink.isSymbolicLink() ||
|
|
110
|
+
!rootLink.isDirectory() ||
|
|
111
|
+
(rootLink.mode & 0o077) !== 0) {
|
|
112
|
+
throw new Error("Invalid Agent runtime secret root");
|
|
113
|
+
}
|
|
114
|
+
const lexicalExpectedPath = path.join(path.resolve(runtimeRootValue), profileId, "service-credential");
|
|
115
|
+
if (path.resolve(credentialPathValue) !== lexicalExpectedPath) {
|
|
116
|
+
throw new Error("Invalid Agent service credential file binding");
|
|
117
|
+
}
|
|
118
|
+
const runtimeRoot = fs.realpathSync(runtimeRootValue);
|
|
119
|
+
const expectedPath = path.join(runtimeRoot, profileId, "service-credential");
|
|
120
|
+
const parent = path.dirname(expectedPath);
|
|
121
|
+
const parentLink = fs.lstatSync(parent);
|
|
122
|
+
const credentialLink = fs.lstatSync(expectedPath);
|
|
123
|
+
const currentUid = process.getuid?.();
|
|
124
|
+
if (parentLink.isSymbolicLink() ||
|
|
125
|
+
!parentLink.isDirectory() ||
|
|
126
|
+
(parentLink.mode & 0o077) !== 0 ||
|
|
127
|
+
credentialLink.isSymbolicLink() ||
|
|
128
|
+
!credentialLink.isFile() ||
|
|
129
|
+
(credentialLink.mode & 0o777) !== 0o600 ||
|
|
130
|
+
credentialLink.size < 8 ||
|
|
131
|
+
credentialLink.size > 4096 ||
|
|
132
|
+
(currentUid !== undefined && credentialLink.uid !== currentUid)) {
|
|
133
|
+
throw new Error("Invalid Agent service credential file");
|
|
134
|
+
}
|
|
135
|
+
if (fs.realpathSync(parent) !== parent || fs.realpathSync(expectedPath) !== expectedPath) {
|
|
136
|
+
throw new Error("Invalid Agent service credential file confinement");
|
|
137
|
+
}
|
|
138
|
+
const token = fs.readFileSync(expectedPath, "utf8").trim();
|
|
139
|
+
const descriptor = raw.serviceCredential;
|
|
140
|
+
const generation = Number(env.SELLABLE_AGENT_CREDENTIAL_GENERATION ?? "");
|
|
141
|
+
const fingerprint = createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
142
|
+
if (!/^sat_[^_\s]+_\S+$/.test(token) ||
|
|
143
|
+
typeof raw.token === "string" ||
|
|
144
|
+
!descriptor ||
|
|
145
|
+
descriptor.fingerprint !== fingerprint ||
|
|
146
|
+
descriptor.generation !== generation ||
|
|
147
|
+
typeof descriptor.reference !== "string" ||
|
|
148
|
+
!/^secret:\/\/agent\/service\/g[1-9][0-9]*$/.test(descriptor.reference)) {
|
|
149
|
+
throw new Error("Invalid Agent service credential descriptor");
|
|
150
|
+
}
|
|
151
|
+
return token;
|
|
152
|
+
}
|
|
90
153
|
export function getLockedWorkspaceId() {
|
|
91
154
|
const value = process.env[WORKSPACE_LOCK_ENV]?.trim();
|
|
92
155
|
return value || null;
|
|
@@ -147,6 +210,7 @@ export function getConfig() {
|
|
|
147
210
|
const raw = JSON.parse(content);
|
|
148
211
|
// Resolve config: support multi-env format or legacy flat format
|
|
149
212
|
let config;
|
|
213
|
+
let selectedRaw;
|
|
150
214
|
if (raw.activeEnv && raw.environments) {
|
|
151
215
|
const envName = raw.activeEnv;
|
|
152
216
|
const envConfig = raw.environments[envName];
|
|
@@ -154,6 +218,7 @@ export function getConfig() {
|
|
|
154
218
|
const available = Object.keys(raw.environments).join(", ");
|
|
155
219
|
throw new Error(`Unknown environment '${envName}' in ${configFileName}. Available: ${available}`);
|
|
156
220
|
}
|
|
221
|
+
selectedRaw = envConfig;
|
|
157
222
|
config = {
|
|
158
223
|
...normalizeConfig(envConfig),
|
|
159
224
|
activeEnvName: envName,
|
|
@@ -161,13 +226,35 @@ export function getConfig() {
|
|
|
161
226
|
}
|
|
162
227
|
else {
|
|
163
228
|
// Legacy flat format: { token, workspaceId?, apiUrl? }
|
|
229
|
+
selectedRaw = raw;
|
|
164
230
|
config = normalizeConfig(raw);
|
|
165
231
|
}
|
|
166
|
-
|
|
232
|
+
const confinedAgentCredential = readConfinedAgentServiceCredential(selectedRaw);
|
|
233
|
+
if (confinedAgentCredential) {
|
|
234
|
+
config.token = confinedAgentCredential;
|
|
235
|
+
config.credentialKind = "agent_service";
|
|
236
|
+
}
|
|
237
|
+
else if (!config.token) {
|
|
167
238
|
throw new Error(`Missing 'token' in ${configFileName}`);
|
|
168
239
|
}
|
|
169
|
-
if (
|
|
170
|
-
|
|
240
|
+
if (config.token.startsWith("skt_")) {
|
|
241
|
+
config.credentialKind = "human";
|
|
242
|
+
}
|
|
243
|
+
else if (config.token.startsWith("sat_")) {
|
|
244
|
+
const agent = readMcpAgentServiceContext(process.env, config.token);
|
|
245
|
+
if (!agent)
|
|
246
|
+
throw new Error("Invalid Agent service context");
|
|
247
|
+
const configuredWorkspace = normalizeWorkspaceId(config.activeWorkspaceId) ||
|
|
248
|
+
normalizeWorkspaceId(config.workspaceId);
|
|
249
|
+
if (configuredWorkspace && configuredWorkspace !== agent.workspaceId) {
|
|
250
|
+
throw new Error("Agent service credential workspace binding mismatch");
|
|
251
|
+
}
|
|
252
|
+
config.activeWorkspaceId = agent.workspaceId;
|
|
253
|
+
config.workspaceId = agent.workspaceId;
|
|
254
|
+
config.credentialKind = "agent_service";
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
throw new Error("Invalid token format - expected a Sellable credential");
|
|
171
258
|
}
|
|
172
259
|
return config;
|
|
173
260
|
}
|
|
@@ -178,7 +265,38 @@ export function getConfig() {
|
|
|
178
265
|
throw error;
|
|
179
266
|
}
|
|
180
267
|
}
|
|
268
|
+
/** Reads only the credential class; never returns or validates credential bytes. */
|
|
269
|
+
export function getConfiguredCredentialIntent() {
|
|
270
|
+
const configPath = getConfigPath();
|
|
271
|
+
if (!fs.existsSync(configPath))
|
|
272
|
+
return "unconfigured";
|
|
273
|
+
try {
|
|
274
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
275
|
+
const selected = raw?.activeEnv && raw?.environments
|
|
276
|
+
? raw.environments[raw.activeEnv]
|
|
277
|
+
: raw;
|
|
278
|
+
const token = selected?.token;
|
|
279
|
+
if (process.env[AGENT_SERVICE_CREDENTIAL_FILE_ENV]?.trim()) {
|
|
280
|
+
return token == null && selected?.serviceCredential
|
|
281
|
+
? "agent_service"
|
|
282
|
+
: "invalid";
|
|
283
|
+
}
|
|
284
|
+
if (typeof token !== "string")
|
|
285
|
+
return "invalid";
|
|
286
|
+
if (token.startsWith("sat_"))
|
|
287
|
+
return "agent_service";
|
|
288
|
+
if (token.startsWith("skt_"))
|
|
289
|
+
return "human";
|
|
290
|
+
return "invalid";
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return "invalid";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
181
296
|
export function updateActiveWorkspace(params) {
|
|
297
|
+
if (getConfig().credentialKind === "agent_service") {
|
|
298
|
+
throw new Error("Agent service profiles cannot switch workspaces");
|
|
299
|
+
}
|
|
182
300
|
resolveWorkspaceIdForRequest(params.workspaceId, "set_active_workspace");
|
|
183
301
|
const configPath = getConfigPath();
|
|
184
302
|
const configDir = path.dirname(configPath);
|