@tokenoftrust/cli 2.2.2 → 2.2.4
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/package.json +1 -1
- package/src/commands/accept.mjs +5 -10
- package/src/commands/branches.mjs +3 -2
- package/src/commands/cleanup.mjs +4 -3
- package/src/commands/clone.mjs +20 -67
- package/src/commands/pr.mjs +4 -4
- package/src/commands/rollback.mjs +8 -8
- package/src/commands/ship.mjs +4 -4
- package/src/commands/submit.mjs +33 -29
- package/src/mcp.mjs +8 -4
- package/src/tenant-scope.mjs +184 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/accept.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { resolveOperatorSecret } from "./ship.mjs";
|
|
|
45
45
|
// same way `tot pr` reaches `candidate_status`/`candidate_close`.
|
|
46
46
|
import { createMcpClient } from "../mcp.mjs";
|
|
47
47
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
48
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
48
49
|
// No-operator-secret path: mint a viewer session from the developer's OWN `tot`
|
|
49
50
|
// login and integrate as themselves (server gates on their live ship-on-behalf grant).
|
|
50
51
|
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
@@ -270,8 +271,8 @@ export function notMergeableOfferLines(tenant, { pr, changeId }) {
|
|
|
270
271
|
* Run `candidate_refresh` over the MCP client — the ONE-CLICK rebuild of a
|
|
271
272
|
* not-mergeable candidate onto the current preview tip. `candidate_refresh` has no
|
|
272
273
|
* operator-secret HTTP route, so this reaches it exactly as `tot pr` reaches
|
|
273
|
-
* `candidate_status`: an OAuth developer session (`tot login`)
|
|
274
|
-
*
|
|
274
|
+
* `candidate_status`: an OAuth developer session (`tot login`) scoped to the tenant with
|
|
275
|
+
* `scopeToTenant`. `createClient`/`establishSession` are injected so it's
|
|
275
276
|
* unit-tested with no live MCP.
|
|
276
277
|
*
|
|
277
278
|
* @param {{ tenant:string, changeId:string, strategy:string, mcpUrl?:string|null,
|
|
@@ -289,14 +290,8 @@ export async function runRefresh(
|
|
|
289
290
|
const client = create(baseUrl);
|
|
290
291
|
try {
|
|
291
292
|
await establish(client, { env, prefer: identity || undefined });
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
try {
|
|
295
|
-
await client.callTool("client_switch", { tenant });
|
|
296
|
-
} catch {
|
|
297
|
-
/* best-effort scope bind */
|
|
298
|
-
}
|
|
299
|
-
const raw = await client.callTool("candidate_refresh", { repo: tenant, changeId, strategy });
|
|
293
|
+
const scoped = await scopeToTenant(client, tenant);
|
|
294
|
+
const raw = await scoped.callTool("candidate_refresh", { repo: tenant, changeId, strategy });
|
|
300
295
|
return normalizeRefreshResult(raw);
|
|
301
296
|
} catch (e) {
|
|
302
297
|
if (e instanceof AuthUnavailableError) {
|
|
@@ -30,6 +30,7 @@ import { execFileSync } from "node:child_process";
|
|
|
30
30
|
import { createMcpClient } from "../mcp.mjs";
|
|
31
31
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
32
32
|
import { fail } from "../errors.mjs";
|
|
33
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
33
34
|
import { repoNameFromRemote } from "./submit.mjs";
|
|
34
35
|
|
|
35
36
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -256,9 +257,9 @@ export async function run(argv, ctx) {
|
|
|
256
257
|
const client = createMcpClient(baseUrl);
|
|
257
258
|
try {
|
|
258
259
|
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
259
|
-
|
|
260
|
+
const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
|
|
260
261
|
|
|
261
|
-
const report = await
|
|
262
|
+
const report = await scoped.callTool("candidate_list", {
|
|
262
263
|
repo,
|
|
263
264
|
...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
|
|
264
265
|
? { staleAfterDays: args.staleAfterDays }
|
package/src/commands/cleanup.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { createMcpClient } from "../mcp.mjs";
|
|
28
28
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
29
29
|
import { fail } from "../errors.mjs";
|
|
30
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
30
31
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
31
32
|
import { resolveRepoAndTenant, renderBranchesTable, formatBranchRow } from "./branches.mjs";
|
|
32
33
|
|
|
@@ -154,9 +155,9 @@ export async function run(argv, ctx) {
|
|
|
154
155
|
const client = createMcpClient(baseUrl);
|
|
155
156
|
try {
|
|
156
157
|
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
157
|
-
|
|
158
|
+
const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
|
|
158
159
|
|
|
159
|
-
const report = await
|
|
160
|
+
const report = await scoped.callTool("candidate_list", {
|
|
160
161
|
repo,
|
|
161
162
|
...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
|
|
162
163
|
? { staleAfterDays: args.staleAfterDays }
|
|
@@ -226,7 +227,7 @@ export async function run(argv, ctx) {
|
|
|
226
227
|
let failures = 0;
|
|
227
228
|
for (const b of eligible) {
|
|
228
229
|
try {
|
|
229
|
-
const res = await
|
|
230
|
+
const res = await scoped.callTool("candidate_delete", {
|
|
230
231
|
repo,
|
|
231
232
|
ref: b.ref,
|
|
232
233
|
expectedSha: b.sha,
|
package/src/commands/clone.mjs
CHANGED
|
@@ -58,6 +58,7 @@ import { createMcpClient } from "../mcp.mjs";
|
|
|
58
58
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
59
59
|
import { offerSignIn } from "./login.mjs";
|
|
60
60
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
61
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
61
62
|
import { writeNvmrc } from "../sample.mjs";
|
|
62
63
|
import { emitObstacle } from "../obstacle.mjs";
|
|
63
64
|
import {
|
|
@@ -364,41 +365,8 @@ export function checkoutPointsAtEntitlement(checkout) {
|
|
|
364
365
|
}
|
|
365
366
|
|
|
366
367
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* error)? The distinction is load-bearing: an expected refusal is a normal step on the
|
|
370
|
-
* staff-preview path and must be tolerated, while anything else means the session itself
|
|
371
|
-
* is unusable and must surface immediately rather than be mistaken for an authorization
|
|
372
|
-
* problem later.
|
|
373
|
-
*
|
|
374
|
-
* Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
|
|
375
|
-
* text is not a contract. The text fallback exists only for servers predating a typed code
|
|
376
|
-
* and is deliberately narrow — when in doubt this returns false, which escalates. Treating
|
|
377
|
-
* an unknown failure as "expected" is the unsafe direction: it would let a dead connection
|
|
378
|
-
* pass for a routine refusal.
|
|
379
|
-
*
|
|
380
|
-
* Pure + exported so it is unit-tested without any I/O.
|
|
381
|
-
* @param {unknown} error
|
|
382
|
-
* @returns {boolean}
|
|
383
|
-
*/
|
|
384
|
-
export function isNotClientTenantRefusal(error) {
|
|
385
|
-
const e = /** @type {any} */ (error);
|
|
386
|
-
if (!e) return false;
|
|
387
|
-
// A transport/protocol-level failure is never an authorization refusal.
|
|
388
|
-
const category = e?.diagnostic?.category ?? e?.category;
|
|
389
|
-
if (category && category !== "tool_error") return false;
|
|
390
|
-
const code = e?.diagnostic?.serverCode ?? e?.serverCode;
|
|
391
|
-
if (typeof code === "string") {
|
|
392
|
-
return code === "not_client_tenant" || code === "over_scoped";
|
|
393
|
-
}
|
|
394
|
-
// Text fallback (pre-typed-code servers) — narrow on purpose.
|
|
395
|
-
const message = typeof e?.message === "string" ? e.message : "";
|
|
396
|
-
return /not one of your client tenants/i.test(message);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* The privileged checkout core, composable in-process (used by `tot clone`
|
|
401
|
-
* and by `tot start`): client_switch → tenant_checkout → optionally clone. The
|
|
368
|
+
* The privileged checkout core, composable in-process (used by `tot clone`, `tot start`
|
|
369
|
+
* and `tot git-credential`): scope to the tenant → tenant_checkout → optionally clone. The
|
|
402
370
|
* MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
|
|
403
371
|
* already initialized + has a validated session.
|
|
404
372
|
*
|
|
@@ -408,38 +376,23 @@ export function isNotClientTenantRefusal(error) {
|
|
|
408
376
|
* cloned: boolean, dir: string|null, head: string|null }>}
|
|
409
377
|
*/
|
|
410
378
|
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
411
|
-
// tenant_checkout is the SOLE AUTHORITATIVE checkout decision.
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
379
|
+
// tenant_checkout is the SOLE AUTHORITATIVE checkout decision. The pin's refusal may be
|
|
380
|
+
// entirely expected: a staff human checking out a merchant tenant under a staff-preview
|
|
381
|
+
// authorization holds no client grant on it, so the pin MUST refuse while the checkout
|
|
382
|
+
// legitimately succeeds. `tot git-credential` mints through this same function, so
|
|
383
|
+
// treating the pin as fatal breaks every fetch and push in such a checkout, not only the
|
|
384
|
+
// clone. scopeToTenant remembers that expected refusal as `pinRefusal` and throws at once
|
|
385
|
+
// on any other pin failure (a broken session must not masquerade as an authorization
|
|
386
|
+
// problem).
|
|
417
387
|
//
|
|
418
|
-
// Two
|
|
419
|
-
// - discarding
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// An UNEXPECTED switch failure — transport, auth, a malformed envelope — is not the
|
|
427
|
-
// not-a-client case at all. It means the session itself is broken, and carrying it
|
|
428
|
-
// silently would let a dead connection masquerade as an authorization problem, so it is
|
|
429
|
-
// raised immediately rather than remembered.
|
|
430
|
-
let expectedSwitchRefusal = null;
|
|
431
|
-
try {
|
|
432
|
-
await client.callTool("client_switch", { tenant });
|
|
433
|
-
} catch (e) {
|
|
434
|
-
if (isNotClientTenantRefusal(e)) {
|
|
435
|
-
expectedSwitchRefusal = e;
|
|
436
|
-
} else {
|
|
437
|
-
throw new CliError(redact(String(e?.message || e)), {
|
|
438
|
-
next: "the session could not be scoped — check your connection and run `tot whoami`",
|
|
439
|
-
cause: e,
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
}
|
|
388
|
+
// Two uses of the remembered refusal are BOTH wrong, and the second is the dangerous one:
|
|
389
|
+
// - discarding it loses the only good explanation when a tenant genuinely is not yours;
|
|
390
|
+
// - letting it OVERRIDE a checkout denial replaces an authoritative answer ("your staff
|
|
391
|
+
// authorization expired / was revoked / does not cover this resource") with grant
|
|
392
|
+
// guidance that sends you chasing the wrong thing entirely.
|
|
393
|
+
// So an expected pin refusal may never speak over a checkout denial (see below).
|
|
394
|
+
const scoped = await scopeToTenant(client, tenant, { redact });
|
|
395
|
+
const expectedSwitchRefusal = scoped.pinRefusal;
|
|
443
396
|
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
444
397
|
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
445
398
|
// `appForCaller` for them instead of 403-ing "No version-control app is bound." The
|
|
@@ -458,7 +411,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
458
411
|
}
|
|
459
412
|
let checkout;
|
|
460
413
|
try {
|
|
461
|
-
checkout = await
|
|
414
|
+
checkout = await scoped.callTool("tenant_checkout", { tag });
|
|
462
415
|
} catch (e) {
|
|
463
416
|
// A THROWN checkout is not a denial — it never rendered an authorization decision. Only
|
|
464
417
|
// here may the expected switch refusal speak, because it is the only explanation either
|
package/src/commands/pr.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { execFileSync } from "node:child_process";
|
|
|
27
27
|
import { createMcpClient } from "../mcp.mjs";
|
|
28
28
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
29
29
|
import { fail } from "../errors.mjs";
|
|
30
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
30
31
|
import { repoNameFromRemote, currentBranch } from "./submit.mjs";
|
|
31
32
|
// `tot pr list` reuses `tot ship --pr`'s operator-secret transport helpers verbatim
|
|
32
33
|
// (same env precedence, same queue normalisation) so the two speak one wire, not two.
|
|
@@ -368,10 +369,9 @@ export async function run(argv, ctx) {
|
|
|
368
369
|
try {
|
|
369
370
|
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
370
371
|
void session;
|
|
371
|
-
|
|
372
|
-
await client.callTool("client_switch", { tenant });
|
|
372
|
+
const scoped = await scopeToTenant(client, tenant);
|
|
373
373
|
|
|
374
|
-
const candidates = extractCandidates(await
|
|
374
|
+
const candidates = extractCandidates(await scoped.callTool("candidate_status", { repo }));
|
|
375
375
|
|
|
376
376
|
if (args.sub === "list") {
|
|
377
377
|
if (!candidates.length) {
|
|
@@ -408,7 +408,7 @@ export async function run(argv, ctx) {
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
// close
|
|
411
|
-
await
|
|
411
|
+
await scoped.callTool("candidate_close", {
|
|
412
412
|
repo,
|
|
413
413
|
changeId: match.changeId,
|
|
414
414
|
...(args.reason ? { reason: args.reason } : {}),
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { createMcpClient } from "../mcp.mjs";
|
|
37
37
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
38
38
|
import { fail } from "../errors.mjs";
|
|
39
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
39
40
|
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
40
41
|
import { startProgress } from "../progress.mjs";
|
|
41
42
|
|
|
@@ -224,7 +225,7 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
|
|
|
224
225
|
* confirm → commit (with a target). Split out from `run` so it's driven in tests
|
|
225
226
|
* with a mock client + injected confirm/interactive, no network/TTY.
|
|
226
227
|
*
|
|
227
|
-
* @param {{callTool:Function}}
|
|
228
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
228
229
|
* @param {{ tenant:string, target:string, toVersionId:string|null,
|
|
229
230
|
* revisionBase?:string|null }} params `revisionBase` is the storefront origin
|
|
230
231
|
* the listing's `/rev/<sha>` links resolve against; omit to list
|
|
@@ -233,7 +234,7 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
|
|
|
233
234
|
* progress?:boolean }} [deps]
|
|
234
235
|
* @returns {Promise<number>} process exit code
|
|
235
236
|
*/
|
|
236
|
-
export async function runRollback(
|
|
237
|
+
export async function runRollback(scoped, { tenant, target, toVersionId, revisionBase }, deps = {}) {
|
|
237
238
|
const interactive = deps.interactive || isInteractive;
|
|
238
239
|
const confirm = deps.confirm || promptYesNo;
|
|
239
240
|
|
|
@@ -241,7 +242,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
|
|
|
241
242
|
if (!toVersionId) {
|
|
242
243
|
let status;
|
|
243
244
|
try {
|
|
244
|
-
status = normalizeStatus(await
|
|
245
|
+
status = normalizeStatus(await scoped.callTool("promotion_status", { target }));
|
|
245
246
|
} catch (e) {
|
|
246
247
|
console.error(
|
|
247
248
|
fail(
|
|
@@ -272,7 +273,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
|
|
|
272
273
|
let preview;
|
|
273
274
|
try {
|
|
274
275
|
preview = normalizeRollbackPreview(
|
|
275
|
-
await
|
|
276
|
+
await scoped.callTool("promotion_rollback", { target, toVersionId, dryRun: true }),
|
|
276
277
|
);
|
|
277
278
|
} catch (e) {
|
|
278
279
|
console.error(
|
|
@@ -320,7 +321,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
|
|
|
320
321
|
let result;
|
|
321
322
|
try {
|
|
322
323
|
result = normalizeRollbackResult(
|
|
323
|
-
await
|
|
324
|
+
await scoped.callTool("promotion_rollback", { target, toVersionId, dryRun: false }),
|
|
324
325
|
);
|
|
325
326
|
} catch (e) {
|
|
326
327
|
console.error(
|
|
@@ -383,10 +384,9 @@ export async function run(argv, ctx) {
|
|
|
383
384
|
const client = createMcpClient(baseUrl);
|
|
384
385
|
try {
|
|
385
386
|
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
386
|
-
|
|
387
|
-
await client.callTool("client_switch", { tenant });
|
|
387
|
+
const scoped = await scopeToTenant(client, tenant);
|
|
388
388
|
|
|
389
|
-
return await runRollback(
|
|
389
|
+
return await runRollback(scoped, { tenant, target, toVersionId: args.versionId, revisionBase });
|
|
390
390
|
} catch (e) {
|
|
391
391
|
if (e instanceof AuthUnavailableError) {
|
|
392
392
|
console.error(fail("sign in to roll back", e.hint || "run `tot login`, then re-run `tot rollback`"));
|
package/src/commands/ship.mjs
CHANGED
|
@@ -188,15 +188,15 @@ export function normalizeChangeResult(r) {
|
|
|
188
188
|
/**
|
|
189
189
|
* Poll `change_status` until the change reports shipped (or the attempts budget
|
|
190
190
|
* runs out). Injectable delay/attempts.
|
|
191
|
-
* @param {{callTool:Function}}
|
|
192
|
-
* @param {{ id:string
|
|
191
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
192
|
+
* @param {{ id:string }} target
|
|
193
193
|
* @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
|
|
194
194
|
*/
|
|
195
|
-
export async function pollChangeShipped(
|
|
195
|
+
export async function pollChangeShipped(scoped, { id }, { attempts = 6, delayMs = 1500, sleep } = {}) {
|
|
196
196
|
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
197
197
|
let last = null;
|
|
198
198
|
for (let i = 0; i < attempts; i++) {
|
|
199
|
-
last = normalizeChangeResult(await
|
|
199
|
+
last = normalizeChangeResult(await scoped.callTool("change_status", { id }));
|
|
200
200
|
if (last.shipped) return last;
|
|
201
201
|
if (i < attempts - 1) await wait(delayMs);
|
|
202
202
|
}
|
package/src/commands/submit.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
45
45
|
import { createMcpClient } from "../mcp.mjs";
|
|
46
46
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
47
47
|
import { checkoutTenant } from "./clone.mjs";
|
|
48
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
48
49
|
// Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
|
|
49
50
|
// born-rebased submit rebuilds a candidate onto the current base with the SAME
|
|
50
51
|
// engine `tot accept --refresh` uses, so the two read its result identically).
|
|
@@ -497,13 +498,13 @@ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
|
|
|
497
498
|
* throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
|
|
498
499
|
* without the tool, a transient failure — normalizes to a non-ok error result the
|
|
499
500
|
* caller submits-as-is on, NEVER blocking the push that already landed.
|
|
500
|
-
* @param {{callTool:Function}}
|
|
501
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
501
502
|
* @param {{ repo: string, changeId: string, strategy: string }} opts
|
|
502
503
|
* @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
|
|
503
504
|
*/
|
|
504
|
-
export async function runBornRebased(
|
|
505
|
+
export async function runBornRebased(scoped, { repo, changeId, strategy }) {
|
|
505
506
|
try {
|
|
506
|
-
const raw = await
|
|
507
|
+
const raw = await scoped.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
507
508
|
return normalizeRefreshResult(raw);
|
|
508
509
|
} catch (e) {
|
|
509
510
|
const diagnostic = recordDiagnostic(e, {
|
|
@@ -957,13 +958,13 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
|
957
958
|
* that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
|
|
958
959
|
* Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
|
|
959
960
|
* plain array. Injectable client for tests.
|
|
960
|
-
* @param {{callTool:Function}}
|
|
961
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
961
962
|
* @param {{ repo: string, changeId: string }} opts
|
|
962
963
|
* @returns {Promise<string|null>}
|
|
963
964
|
*/
|
|
964
|
-
export async function candidateStateFor(
|
|
965
|
+
export async function candidateStateFor(scoped, { repo, changeId }) {
|
|
965
966
|
try {
|
|
966
|
-
const r = await
|
|
967
|
+
const r = await scoped.callTool("candidate_status", { repo, changeId });
|
|
967
968
|
const c = Array.isArray(r)
|
|
968
969
|
? r.find((x) => x?.changeId === changeId)
|
|
969
970
|
: Array.isArray(r?.candidates)
|
|
@@ -993,13 +994,13 @@ export async function candidateStateFor(client, { repo, changeId }) {
|
|
|
993
994
|
* state drops the pointer. When it does, `dropped` carries the old changeId + the
|
|
994
995
|
* terminal state so the caller can tell the operator and forget the on-disk pointer.
|
|
995
996
|
* Injectable client for tests.
|
|
996
|
-
* @param {{callTool:Function}}
|
|
997
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
997
998
|
* @param {{ repo: string|null, active: string|null }} opts
|
|
998
999
|
* @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
|
|
999
1000
|
*/
|
|
1000
|
-
export async function resolveActivePointer(
|
|
1001
|
+
export async function resolveActivePointer(scoped, { repo, active }) {
|
|
1001
1002
|
if (!active || !repo) return { active };
|
|
1002
|
-
const state = await candidateStateFor(
|
|
1003
|
+
const state = await candidateStateFor(scoped, { repo, changeId: active });
|
|
1003
1004
|
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
|
|
1004
1005
|
return { active };
|
|
1005
1006
|
}
|
|
@@ -1039,19 +1040,19 @@ export class CandidateRegistrationError extends Error {
|
|
|
1039
1040
|
* has already pushed its real commits, so this call sends only the exact candidate
|
|
1040
1041
|
* ref and asserted HEAD plus PR metadata. Any registration failure is fatal: a
|
|
1041
1042
|
* pushed ref without a review PR is not a successful submit.
|
|
1042
|
-
* @param {
|
|
1043
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
1043
1044
|
* @param {{ repo: string|null, changeId: string, ref: string, headSha: string,
|
|
1044
1045
|
* changeSummary: {title:string, body:string[]}, hasChanges: boolean,
|
|
1045
1046
|
* quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
|
|
1046
1047
|
* result is still returned for the caller's JSON payload.
|
|
1047
1048
|
*/
|
|
1048
|
-
export async function submitCandidate(
|
|
1049
|
+
export async function submitCandidate(scoped, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
|
|
1049
1050
|
if (!repo) {
|
|
1050
1051
|
throw new CandidateRegistrationError("Candidate not created: couldn't derive the forge repo from origin.");
|
|
1051
1052
|
}
|
|
1052
1053
|
if (!hasChanges) throw new CandidateRegistrationError("Candidate not created: no content changes were found.");
|
|
1053
1054
|
try {
|
|
1054
|
-
const result = await
|
|
1055
|
+
const result = await scoped.callTool("candidate_open", {
|
|
1055
1056
|
repo,
|
|
1056
1057
|
changeId,
|
|
1057
1058
|
// Defensively bounded to candidate_open's own schema limits (title<=200,
|
|
@@ -1536,17 +1537,20 @@ export async function run(argv, ctx, {
|
|
|
1536
1537
|
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1537
1538
|
// guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
|
|
1538
1539
|
// there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1540
|
+
// Runs scoped to the tenant (idempotent with the later scoping / the fresh-mint
|
|
1541
|
+
// checkoutTenant); a session that cannot be scoped leaves the pointer untouched.
|
|
1541
1542
|
if (active && repo && !args.forkCandidate) {
|
|
1543
|
+
let pointerScope = null;
|
|
1542
1544
|
try {
|
|
1543
|
-
await client
|
|
1545
|
+
pointerScope = await scopeToTenant(client, tenant);
|
|
1544
1546
|
} catch (error) {
|
|
1545
1547
|
recordDiagnostic(error, {
|
|
1546
1548
|
command: verb, operation: "client_switch", required: false, degraded: true,
|
|
1547
1549
|
}, env);
|
|
1548
1550
|
}
|
|
1549
|
-
const resolved =
|
|
1551
|
+
const resolved = pointerScope
|
|
1552
|
+
? await resolveActivePointer(pointerScope, { repo, active })
|
|
1553
|
+
: /** @type {Awaited<ReturnType<typeof resolveActivePointer>>} */ ({ active });
|
|
1550
1554
|
if (resolved.dropped) {
|
|
1551
1555
|
console.error(
|
|
1552
1556
|
`~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
|
|
@@ -1611,8 +1615,7 @@ export async function run(argv, ctx, {
|
|
|
1611
1615
|
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
1612
1616
|
// hours, so we re-mint right before the push and hand it to git ephemerally
|
|
1613
1617
|
// (never persisted to `.git/config`), re-minting once on an auth failure.
|
|
1614
|
-
// `checkoutTenant(cloneDir:null)` mints without re-cloning
|
|
1615
|
-
// binding the tenant scope the candidate/preview read-back below reads.
|
|
1618
|
+
// `checkoutTenant(cloneDir:null)` mints without re-cloning.
|
|
1616
1619
|
const tag = tagFromRepoName(repo, tenant);
|
|
1617
1620
|
const mintRemote = async () => {
|
|
1618
1621
|
try {
|
|
@@ -1661,21 +1664,22 @@ export async function run(argv, ctx, {
|
|
|
1661
1664
|
let operation = "client_switch";
|
|
1662
1665
|
const candidateRequired = changedEntries.length > 0;
|
|
1663
1666
|
try {
|
|
1664
|
-
//
|
|
1665
|
-
// (idempotent
|
|
1667
|
+
// Every candidate/preview call below goes through this tenant-scoped handle
|
|
1668
|
+
// (idempotent with checkoutTenant's own scoping when the fresh mint succeeded).
|
|
1669
|
+
let scoped;
|
|
1666
1670
|
try {
|
|
1667
|
-
await client
|
|
1671
|
+
scoped = await scopeToTenant(client, tenant);
|
|
1668
1672
|
} catch (e) {
|
|
1669
1673
|
throw new CandidateRegistrationError(
|
|
1670
1674
|
`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`,
|
|
1671
|
-
e,
|
|
1675
|
+
/** @type {any} */ (e)?.cause ?? e,
|
|
1672
1676
|
);
|
|
1673
1677
|
}
|
|
1674
1678
|
|
|
1675
1679
|
// 2b. Register the exact ref and asserted commit that git just pushed. No file
|
|
1676
1680
|
// bytes cross MCP and the server does not reconstruct or replace these commits.
|
|
1677
1681
|
operation = "candidate_open";
|
|
1678
|
-
candidate = await submitCandidate(
|
|
1682
|
+
candidate = await submitCandidate(scoped, {
|
|
1679
1683
|
repo, changeId, ref, headSha: commit, changeSummary,
|
|
1680
1684
|
hasChanges: changedEntries.length > 0, quiet: args.json,
|
|
1681
1685
|
});
|
|
@@ -1699,7 +1703,7 @@ export async function run(argv, ctx, {
|
|
|
1699
1703
|
// baseDrift (0 when skipped).
|
|
1700
1704
|
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1701
1705
|
operation = "candidate_refresh";
|
|
1702
|
-
const rebased = await runBornRebased(
|
|
1706
|
+
const rebased = await runBornRebased(scoped, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1703
1707
|
if (rebased.ok) {
|
|
1704
1708
|
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
1705
1709
|
if (!args.json) console.log(line);
|
|
@@ -1757,7 +1761,7 @@ export async function run(argv, ctx, {
|
|
|
1757
1761
|
let status;
|
|
1758
1762
|
operation = "preview_status";
|
|
1759
1763
|
if (args.noWait) {
|
|
1760
|
-
status = normalizePreviewStatus(await
|
|
1764
|
+
status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }));
|
|
1761
1765
|
} else {
|
|
1762
1766
|
// One in-place status line (TTY: a spinner with an elapsed-seconds counter;
|
|
1763
1767
|
// non-TTY: a ~10s heartbeat) instead of a newline per poll — the wait reads
|
|
@@ -1777,7 +1781,7 @@ export async function run(argv, ctx, {
|
|
|
1777
1781
|
stages: [{ afterMs: 45_000, text: `still reconciling ${statusShort}… (larger changes take longer)` }],
|
|
1778
1782
|
});
|
|
1779
1783
|
}
|
|
1780
|
-
status = await pollPreviewStatus(
|
|
1784
|
+
status = await pollPreviewStatus(scoped, statusSha, {
|
|
1781
1785
|
...(args.watch ? WATCH_POLL : DEFAULT_POLL),
|
|
1782
1786
|
onTick: (s) => {
|
|
1783
1787
|
// Reconcile is done but we're still waiting on a ship decision (--watch):
|
|
@@ -1900,14 +1904,14 @@ export function previewSubmitSucceeded(status) {
|
|
|
1900
1904
|
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock and `sleep`
|
|
1901
1905
|
* fn for tests (so the fallback-sleep cadence can be asserted deterministically
|
|
1902
1906
|
* off the injected clock instead of real wall-clock elapsed time).
|
|
1903
|
-
* @param {{callTool:Function}}
|
|
1907
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
1904
1908
|
* @param {string} commit
|
|
1905
1909
|
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1906
1910
|
* notDispatchedGraceMs?: number, now?: () => number, sleep?: (ms:number) => Promise<void>,
|
|
1907
1911
|
* onTick?: (s:object,i:number)=>void }} [opts]
|
|
1908
1912
|
*/
|
|
1909
1913
|
export async function pollPreviewStatus(
|
|
1910
|
-
|
|
1914
|
+
scoped,
|
|
1911
1915
|
commit,
|
|
1912
1916
|
{
|
|
1913
1917
|
attempts = 8,
|
|
@@ -1926,7 +1930,7 @@ export async function pollPreviewStatus(
|
|
|
1926
1930
|
for (let i = 0; i < attempts; i++) {
|
|
1927
1931
|
const startedAt = now();
|
|
1928
1932
|
const args = waitMs ? { commit, waitMs } : { commit };
|
|
1929
|
-
last = normalizePreviewStatus(await
|
|
1933
|
+
last = normalizePreviewStatus(await scoped.callTool("preview_status", args));
|
|
1930
1934
|
if (last.dispatched) everDispatched = true;
|
|
1931
1935
|
last.everDispatched = everDispatched;
|
|
1932
1936
|
if (onTick) onTick(last, i);
|
package/src/mcp.mjs
CHANGED
|
@@ -12,6 +12,10 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import { CliDiagnosticError, recordDiagnostic, sanitizeDiagnosticText } from "./diagnostics.mjs";
|
|
14
14
|
|
|
15
|
+
// A server's refusal is written for the person reading it and usually ends with what to do,
|
|
16
|
+
// so it is shown whole. The cap only bounds a runaway body; sanitizing still applies.
|
|
17
|
+
export const MAX_SERVER_MESSAGE = 4000;
|
|
18
|
+
|
|
15
19
|
const TRACE_HEADER = "X-Trace-Id";
|
|
16
20
|
const REQUEST_ID_HEADERS = ["x-request-id", "x-amzn-trace-id", "x-correlation-id"];
|
|
17
21
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
@@ -188,7 +192,7 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
188
192
|
const category = signal.aborted ? "timeout" : "network";
|
|
189
193
|
const message = signal.aborted
|
|
190
194
|
? `${method} timed out after ${timeoutMs}ms`
|
|
191
|
-
: `${method} failed before a response arrived: ${sanitizeDiagnosticText(cause?.message || cause,
|
|
195
|
+
: `${method} failed before a response arrived: ${sanitizeDiagnosticText(cause?.message || cause, MAX_SERVER_MESSAGE)}`;
|
|
192
196
|
throw diagnosticError(message, { operation: method, category, message, traceId }, cause);
|
|
193
197
|
}
|
|
194
198
|
const sid = res.headers.get("Mcp-Session-Id");
|
|
@@ -229,7 +233,7 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
229
233
|
const category = oauth ? "oauth" : res.status === 413 ? "http_413" : `http_${res.status}`;
|
|
230
234
|
const message = sanitizeDiagnosticText(
|
|
231
235
|
failure.message || parsed?.error_description || `${res.status} ${res.statusText}`,
|
|
232
|
-
|
|
236
|
+
MAX_SERVER_MESSAGE,
|
|
233
237
|
);
|
|
234
238
|
throw diagnosticError(`${method} failed: HTTP ${res.status}${message ? ` — ${message}` : ""}`, {
|
|
235
239
|
operation: method, category, httpStatus: res.status, statusText: res.statusText,
|
|
@@ -249,7 +253,7 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
249
253
|
|
|
250
254
|
if (parsed?.error) {
|
|
251
255
|
const failure = safeServerFailure(parsed);
|
|
252
|
-
const message = sanitizeDiagnosticText(failure.message || "JSON-RPC error",
|
|
256
|
+
const message = sanitizeDiagnosticText(failure.message || "JSON-RPC error", MAX_SERVER_MESSAGE);
|
|
253
257
|
throw diagnosticError(`${method} failed: ${message}`, {
|
|
254
258
|
operation: method, category: "json_rpc", httpStatus: res.status,
|
|
255
259
|
statusText: res.statusText, serverCode: failure.serverCode, message,
|
|
@@ -310,7 +314,7 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
310
314
|
}
|
|
311
315
|
const message = sanitizeDiagnosticText(
|
|
312
316
|
payload?.message || payload?.error?.message || tb?.text || `${name} failed`,
|
|
313
|
-
|
|
317
|
+
MAX_SERVER_MESSAGE,
|
|
314
318
|
);
|
|
315
319
|
throw diagnosticError(`${name} failed: ${message}`, {
|
|
316
320
|
operation: name, category: "tool_error",
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE way a `tot` command acts on a store.
|
|
3
|
+
*
|
|
4
|
+
* Every `tot` command is a fresh MCP session, so nothing about "which store" carries over
|
|
5
|
+
* from a previous command. A command that acts on a store calls `scopeToTenant(client,
|
|
6
|
+
* tenant)` once and makes every store call through the handle it returns:
|
|
7
|
+
*
|
|
8
|
+
* - It pins the session with `client_switch`. A server that resolves the tenant of each
|
|
9
|
+
* call from its own `tenant` argument does not need the pin; one that still reads the
|
|
10
|
+
* session's active tenant does, and the pin is also what scopes the repo-addressed tools
|
|
11
|
+
* (PIN_SCOPED_TOOLS) for an invited developer.
|
|
12
|
+
* - It names the tenant on every call to a tool in TENANT_SCOPED_TOOLS — tools whose
|
|
13
|
+
* `tenant` argument means "the tenant this call acts on". The server uses a held tenant
|
|
14
|
+
* directly and refuses one the session does not hold; it never falls back to another.
|
|
15
|
+
* - It refuses a result that reports acting on a different tenant than the one asked for.
|
|
16
|
+
*
|
|
17
|
+
* `test/tenant-scope-contract.test.mjs` fails the build if any file under `src/` calls a
|
|
18
|
+
* TENANT_SCOPED_TOOLS / PIN_SCOPED_TOOLS tool other than through a handle (named `scoped`),
|
|
19
|
+
* or calls `client_switch` anywhere but here, or calls a tool this file does not classify.
|
|
20
|
+
*/
|
|
21
|
+
import { CliError } from "./errors.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tools whose `tenant` argument is the tenant the call acts on. Action-harness tools get the
|
|
25
|
+
* argument from `registerActionTool` (tot-mcp `src/modules/mcp/tools/action-tool.ts`), which
|
|
26
|
+
* resolves it through `resolveCallTenant` and echoes the tenant it acted on in every success
|
|
27
|
+
* result; `tenant_checkout` declares the same argument itself and echoes it too.
|
|
28
|
+
*/
|
|
29
|
+
export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
|
|
30
|
+
"candidate_open",
|
|
31
|
+
"candidate_close",
|
|
32
|
+
"change_status",
|
|
33
|
+
"preview_status",
|
|
34
|
+
"promotion_rollback",
|
|
35
|
+
"promotion_status",
|
|
36
|
+
"tenant_checkout",
|
|
37
|
+
]));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tools that act on a store named by their `repo` argument and take no tenant-of-the-call
|
|
41
|
+
* argument (`candidate_status` / `candidate_list` accept `tenant` only as an alias FOR `repo`;
|
|
42
|
+
* `candidate_delete` / `candidate_refresh` have no `tenant` field and the server drops one).
|
|
43
|
+
* The server confines them to the session's held tenants — for an invited developer, the
|
|
44
|
+
* pinned one — so they go through the handle (which pinned first) with their args untouched.
|
|
45
|
+
*/
|
|
46
|
+
export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
|
|
47
|
+
"candidate_status",
|
|
48
|
+
"candidate_list",
|
|
49
|
+
"candidate_delete",
|
|
50
|
+
"candidate_refresh",
|
|
51
|
+
]));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tools that act on no store, or name their own target with a meaning other than "the
|
|
55
|
+
* tenant of this call" (`repo_read_credential`'s `tenant` is the active scope or a FOREIGN
|
|
56
|
+
* tenant for an authorized cross-tenant read, and its `alias` form takes no tenant at all).
|
|
57
|
+
* Called directly on the client.
|
|
58
|
+
*/
|
|
59
|
+
export const TENANT_NEUTRAL_TOOLS = Object.freeze(new Set([
|
|
60
|
+
"client_list",
|
|
61
|
+
"identity_bind",
|
|
62
|
+
"identity_link_begin",
|
|
63
|
+
"identity_link_poll",
|
|
64
|
+
"grant_introspect",
|
|
65
|
+
"dev_renderer_artifact",
|
|
66
|
+
"dev_image_pull_token",
|
|
67
|
+
"feedback_submit",
|
|
68
|
+
"repo_read_credential",
|
|
69
|
+
]));
|
|
70
|
+
|
|
71
|
+
/** @param {unknown} tenant */
|
|
72
|
+
function normalizeTenant(tenant) {
|
|
73
|
+
return typeof tenant === "string" ? tenant.trim().toLowerCase() : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is this client_switch failure the EXPECTED "that tenant is not one of your clients"
|
|
78
|
+
* refusal, as opposed to a broken session (transport, auth, malformed envelope, internal
|
|
79
|
+
* error)? The distinction is load-bearing: an expected refusal is a normal step on the
|
|
80
|
+
* staff-preview path and must be tolerated, while anything else means the session itself
|
|
81
|
+
* is unusable and must surface immediately rather than be mistaken for an authorization
|
|
82
|
+
* problem later.
|
|
83
|
+
*
|
|
84
|
+
* Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
|
|
85
|
+
* text is not a contract. The text fallback exists only for servers predating a typed code
|
|
86
|
+
* and is deliberately narrow — when in doubt this returns false, which escalates. Treating
|
|
87
|
+
* an unknown failure as "expected" is the unsafe direction: it would let a dead connection
|
|
88
|
+
* pass for a routine refusal.
|
|
89
|
+
*
|
|
90
|
+
* Pure + exported so it is unit-tested without any I/O.
|
|
91
|
+
* @param {unknown} error
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isNotClientTenantRefusal(error) {
|
|
95
|
+
const e = /** @type {any} */ (error);
|
|
96
|
+
if (!e) return false;
|
|
97
|
+
// A transport/protocol-level failure is never an authorization refusal.
|
|
98
|
+
const category = e?.diagnostic?.category ?? e?.category;
|
|
99
|
+
if (category && category !== "tool_error") return false;
|
|
100
|
+
const code = e?.diagnostic?.serverCode ?? e?.serverCode;
|
|
101
|
+
if (typeof code === "string") {
|
|
102
|
+
return code === "not_client_tenant" || code === "over_scoped";
|
|
103
|
+
}
|
|
104
|
+
// Text fallback (pre-typed-code servers) — narrow on purpose.
|
|
105
|
+
const message = typeof e?.message === "string" ? e.message : "";
|
|
106
|
+
return /not one of your client tenants/i.test(message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @typedef {{
|
|
111
|
+
* tenant: string,
|
|
112
|
+
* pinRefusal: any,
|
|
113
|
+
* callTool: (name: string, args?: Record<string, unknown>) => Promise<any>,
|
|
114
|
+
* }} ScopedClient
|
|
115
|
+
* `pinRefusal` is the expected client_switch refusal (see isNotClientTenantRefusal) when
|
|
116
|
+
* the pin was refused, else null. The calls still name the tenant, so the server answers
|
|
117
|
+
* them authoritatively; `pinRefusal` is only the explanation a caller may add when no
|
|
118
|
+
* call produced a better one.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pin the session to `tenant` and return the handle every store call goes through.
|
|
123
|
+
*
|
|
124
|
+
* An expected client_switch refusal (the tenant is not one of the caller's clients — the
|
|
125
|
+
* normal case for a staff human under a staff-preview authorization) is remembered on the
|
|
126
|
+
* handle and the calls proceed, each naming the tenant, so the server decides. Any other
|
|
127
|
+
* client_switch failure means the session is unusable and throws a CliError at once.
|
|
128
|
+
*
|
|
129
|
+
* @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
|
|
130
|
+
* @param {string} tenant
|
|
131
|
+
* @param {{ redact?: (s: string) => string }} [opts]
|
|
132
|
+
* @returns {Promise<ScopedClient>}
|
|
133
|
+
*/
|
|
134
|
+
export async function scopeToTenant(client, tenant, { redact = (s) => s } = {}) {
|
|
135
|
+
const requested = normalizeTenant(tenant);
|
|
136
|
+
if (!requested) {
|
|
137
|
+
throw new CliError("no store to act on: scopeToTenant needs a tenant", {
|
|
138
|
+
next: "run this from a `tot clone` checkout, or pass --tenant <appDomain>",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const name = String(tenant).trim();
|
|
142
|
+
|
|
143
|
+
let pinRefusal = null;
|
|
144
|
+
try {
|
|
145
|
+
await client.callTool("client_switch", { tenant: name });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (isNotClientTenantRefusal(e)) {
|
|
148
|
+
pinRefusal = e;
|
|
149
|
+
} else {
|
|
150
|
+
throw new CliError(redact(String(/** @type {any} */ (e)?.message || e)), {
|
|
151
|
+
next: "the session could not be scoped — check your connection and run `tot whoami`",
|
|
152
|
+
cause: e,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} tool
|
|
159
|
+
* @param {Record<string, unknown>} [args]
|
|
160
|
+
*/
|
|
161
|
+
async function callTool(tool, args = {}) {
|
|
162
|
+
if (!TENANT_SCOPED_TOOLS.has(tool)) return client.callTool(tool, args);
|
|
163
|
+
if (args && Object.hasOwn(args, "tenant") && normalizeTenant(args.tenant) !== requested) {
|
|
164
|
+
throw new CliError(
|
|
165
|
+
`refusing ${tool}: the call names tenant "${String(args.tenant)}" but this command is scoped to "${name}"`,
|
|
166
|
+
{ next: "this is a bug in tot — please report it with `tot feedback`" },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const result = await client.callTool(tool, { ...args, tenant: name });
|
|
170
|
+
const echoed = /** @type {any} */ (result)?.tenant;
|
|
171
|
+
if (typeof echoed === "string" && normalizeTenant(echoed) !== requested) {
|
|
172
|
+
throw new CliError(
|
|
173
|
+
`${tool} answered for "${echoed}", not "${name}" — refusing to use a result for another store`,
|
|
174
|
+
{
|
|
175
|
+
next: "tot stopped here. This is a server-side scoping fault: report it with `tot feedback`, " +
|
|
176
|
+
"and run `tot whoami` to see which stores your session holds",
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { tenant: name, pinRefusal, callTool };
|
|
184
|
+
}
|