@tokenoftrust/cli 2.5.1 → 2.5.2
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 +26 -3
- package/bin/tot.mjs +8 -0
- package/package.json +1 -1
- package/src/activity.mjs +5 -5
- package/src/auth.mjs +18 -6
- package/src/commands/clone.mjs +9 -3
- package/src/commands/dev.mjs +3 -3
- package/src/commands/doctor.mjs +2 -2
- package/src/commands/feedback.mjs +2 -2
- package/src/commands/git-credential.mjs +18 -5
- package/src/commands/grants.mjs +2 -2
- package/src/commands/login.mjs +34 -18
- package/src/commands/logout.mjs +7 -5
- package/src/commands/start.mjs +2 -2
- package/src/commands/whoami.mjs +3 -3
- package/src/git-credential.mjs +6 -3
- package/src/oauth.mjs +4 -2
- package/src/obstacle.mjs +3 -3
- package/src/token-store.mjs +138 -63
package/README.md
CHANGED
|
@@ -48,13 +48,36 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
|
|
|
48
48
|
|
|
49
49
|
`tot` talks to the Token of Trust MCP (default `https://mcp.tokenoftrust.com`, override with `--mcp` or `MCP_BASE_URL`). It is **single-plane**: the only identity is **you**, signed in against the MCP over OAuth.
|
|
50
50
|
|
|
51
|
-
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached
|
|
51
|
+
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached privately under `~/.tot/` for that MCP authority and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
|
|
52
52
|
- Not signed in? On a terminal, `tot start` / `tot clone` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
|
|
53
53
|
- The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
|
|
54
54
|
|
|
55
|
+
### QA and production on one machine
|
|
56
|
+
|
|
57
|
+
Sign in to each authority once:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
tot login --mcp https://mcp.qa.tokenoftrust.com
|
|
61
|
+
tot login --mcp https://mcp.tokenoftrust.com
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Both sessions remain available. Commands select `--mcp` first, the checkout's local
|
|
65
|
+
`tot.mcp-url` binding next, then `MCP_BASE_URL` / `TOT_MCP_URL`, then production.
|
|
66
|
+
Cloning records the authenticated authority in local git configuration. In an existing
|
|
67
|
+
store or kit checkout, a successful `tot login --mcp <url>` records its authority.
|
|
68
|
+
The binding contains no token and is never committed; the shared forge hostname does
|
|
69
|
+
not identify an environment.
|
|
70
|
+
|
|
71
|
+
`tot whoami --mcp <url>` inspects one session. `tot logout --mcp <url>` clears only
|
|
72
|
+
that authority in the active profile. Git operations and the local activity bridge
|
|
73
|
+
use the checkout's authority, so a production login leaves QA work signed in.
|
|
74
|
+
Cached tokens are never attached to a different MCP authority. Existing valid
|
|
75
|
+
credentials are converted once into an authority-specific file with owner-only
|
|
76
|
+
permissions. No separate home directory is needed.
|
|
77
|
+
|
|
55
78
|
### Optional: multiple identities at once (`TOT_PROFILE`)
|
|
56
79
|
|
|
57
|
-
**Most people never set this** — the
|
|
80
|
+
**Most people never set this** — the default identity profile holds your sessions for each authority, and `tot login` replaces only the selected authority's session. Reach for a profile only when you want **more than one identity live at the same time**: a staff `@tokenoftrust.com` sign-in alongside a plain developer one, or many parallel test identities. Set it per shell and each gets its own credential file under `~/.tot` (the renderer cache and everything else stay shared):
|
|
58
81
|
|
|
59
82
|
```
|
|
60
83
|
# terminal A # terminal B
|
|
@@ -69,7 +92,7 @@ export TOT_PROFILE=$(uuidgen) # a fresh isolated identity per terminal / test
|
|
|
69
92
|
tot login
|
|
70
93
|
```
|
|
71
94
|
|
|
72
|
-
A clean short name
|
|
95
|
+
A clean short profile name is included in the credential filename; other labels are hashed. Every profile holds independent sessions per MCP authority. `tot whoami` shows the active profile and authority.
|
|
73
96
|
|
|
74
97
|
## Design notes
|
|
75
98
|
|
package/bin/tot.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import "../src/ensure-node.mjs"; // hard Node-version gate — must stay first (see the module doc)
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
|
+
import { selectedAuthority } from "../src/token-store.mjs";
|
|
19
20
|
import { detectContext } from "../src/context.mjs";
|
|
20
21
|
import { printError } from "../src/errors.mjs";
|
|
21
22
|
import { recordActivity, redactArgs } from "../src/activity-log.mjs";
|
|
@@ -362,6 +363,13 @@ function safeSubcommand(cmd, rest) {
|
|
|
362
363
|
async function main() {
|
|
363
364
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
364
365
|
const ctx = detectContext();
|
|
366
|
+
const mcpIndex = rest.indexOf("--mcp");
|
|
367
|
+
const mcpUrl = mcpIndex < 0 ? undefined : rest[mcpIndex + 1];
|
|
368
|
+
if (mcpIndex >= 0 && (!mcpUrl || mcpUrl.startsWith("--"))) throw new Error("--mcp requires an HTTPS URL.");
|
|
369
|
+
// One invocation context drives auth, telemetry and every runner child.
|
|
370
|
+
delete process.env.TOT_SELECTED_MCP_URL;
|
|
371
|
+
process.env.TOT_SELECTED_MCP_URL = selectedAuthority(process.env, { mcpUrl, cwd: ctx.cwd });
|
|
372
|
+
process.env.MCP_BASE_URL = process.env.TOT_SELECTED_MCP_URL;
|
|
365
373
|
const startedAt = Date.now();
|
|
366
374
|
const command = cmd || "(none)";
|
|
367
375
|
resetInvocationDiagnostics({ command, cliVersion: VERSION });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.2",
|
|
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/activity.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* Dependency-free (node: builtins + global fetch, Node ≥22).
|
|
33
33
|
*/
|
|
34
34
|
import { createHash } from "node:crypto";
|
|
35
|
-
import {
|
|
35
|
+
import { readSessionCredentials } from "./token-store.mjs";
|
|
36
36
|
|
|
37
37
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
38
|
// Envelope (mirror of event.ts)
|
|
@@ -235,14 +235,14 @@ export function isActivityDisabled(env = process.env) {
|
|
|
235
235
|
* configured (→ emitActivity is a no-op). Mirrors the resolution the obstacle
|
|
236
236
|
* beacon (obstacle.mjs) and the heartbeat (dev-heartbeat.mjs) use: the env override
|
|
237
237
|
* `TOT_DEV_ACTIVITY_URL`/`TOT_DEV_ACTIVITY_TOKEN` (threaded to runner-spawning
|
|
238
|
-
* paths) wins, else the `activityUrl`/`activityToken` cached in
|
|
238
|
+
* paths) wins, else the `activityUrl`/`activityToken` cached in the selected authority/profile cache.
|
|
239
239
|
*/
|
|
240
240
|
export function resolveActivityBridge(env = process.env) {
|
|
241
241
|
const url = env.TOT_DEV_ACTIVITY_URL;
|
|
242
242
|
const token = env.TOT_DEV_ACTIVITY_TOKEN;
|
|
243
243
|
if (url && token) return { url, token };
|
|
244
244
|
try {
|
|
245
|
-
const creds =
|
|
245
|
+
const creds = readSessionCredentials(env);
|
|
246
246
|
if (creds?.activityUrl && creds?.activityToken) {
|
|
247
247
|
return { url: creds.activityUrl, token: creds.activityToken };
|
|
248
248
|
}
|
|
@@ -264,7 +264,7 @@ export function resolveActor(env = process.env) {
|
|
|
264
264
|
const kind = env.TOT_ACTOR_KIND === "agent" ? "agent" : "dev";
|
|
265
265
|
let seed = "anonymous";
|
|
266
266
|
try {
|
|
267
|
-
const creds =
|
|
267
|
+
const creds = readSessionCredentials(env);
|
|
268
268
|
if (creds?.emailHint) seed = `hint:${creds.emailHint}`;
|
|
269
269
|
else if (creds?.activityToken) seed = `tok:${creds.activityToken}`;
|
|
270
270
|
} catch {
|
|
@@ -282,7 +282,7 @@ export function resolveActor(env = process.env) {
|
|
|
282
282
|
function resolveScope(env = process.env, extra = {}) {
|
|
283
283
|
const scope = /** @type {any} */ ({ ...extra });
|
|
284
284
|
try {
|
|
285
|
-
const creds =
|
|
285
|
+
const creds = readSessionCredentials(env);
|
|
286
286
|
if (creds?.traceId && !scope.traceId) scope.traceId = creds.traceId;
|
|
287
287
|
} catch {
|
|
288
288
|
/* best-effort */
|
package/src/auth.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* SINGLE PLANE: there is exactly one identity that drives `tot` — the human
|
|
6
6
|
* developer, signed in as THEMSELVES against the MCP via OAuth (the same identity
|
|
7
7
|
* `claude mcp add … tot` + `/mcp` establishes). `tot login` runs the MCP OAuth
|
|
8
|
-
* PKCE loopback and caches the token (
|
|
8
|
+
* PKCE loopback and caches the token (the selected authority/profile cache via token-store.mjs);
|
|
9
9
|
* this provider reads it back, silently refreshing when expired, and attaches it as
|
|
10
10
|
* the client's Bearer. Entitlement is derived server-side from the developer's ToT
|
|
11
11
|
* memberships — no local check. Every command calls `establishSession(client)`.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* read or transmit the secret.
|
|
21
21
|
*/
|
|
22
22
|
import {
|
|
23
|
-
defaultCredentialsPath, readCredentials, writeCredentials, isExpired,
|
|
23
|
+
defaultCredentialsPath, readCredentials, writeCredentials, isExpired, normalizeAuthority, selectedAuthority,
|
|
24
24
|
} from "./token-store.mjs";
|
|
25
25
|
import { refreshAccessToken, credentialsFromToken } from "./oauth.mjs";
|
|
26
26
|
|
|
@@ -140,19 +140,31 @@ export function warnLegacyOperatorEnv(env = process.env, warn = (m) => console.e
|
|
|
140
140
|
*/
|
|
141
141
|
export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
142
142
|
const { fetchImpl = fetch, now = Date.now() } = deps;
|
|
143
|
-
const
|
|
143
|
+
const authority = selectedAuthority(env, { mcpUrl: client?.mcpUrl });
|
|
144
|
+
const path = defaultCredentialsPath(env, authority);
|
|
144
145
|
let creds = readCredentials(path);
|
|
145
146
|
|
|
146
|
-
if (!creds || !creds.accessToken) {
|
|
147
|
+
if (!creds || typeof creds.accessToken !== "string" || !creds.accessToken) {
|
|
147
148
|
throw new AuthUnavailableError(
|
|
148
149
|
"you're not signed in to Token of Trust.",
|
|
149
150
|
{
|
|
150
151
|
reason: "missing",
|
|
151
|
-
hint:
|
|
152
|
+
hint: `run \`tot login --mcp ${authority}\` to sign in, then re-run.`,
|
|
152
153
|
},
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
let matches = false;
|
|
158
|
+
try {
|
|
159
|
+
matches = normalizeAuthority(creds.mcpUrl) === authority &&
|
|
160
|
+
(!creds.tokenEndpoint || normalizeAuthority(creds.tokenEndpoint) === authority);
|
|
161
|
+
} catch { /* malformed cache is unauthenticated */ }
|
|
162
|
+
if (!matches) {
|
|
163
|
+
throw new AuthUnavailableError("your cached session does not match this MCP authority.", {
|
|
164
|
+
reason: "authorityMismatch", hint: `run \`tot login --mcp ${authority}\`, then re-run.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
if (isExpired(creds, { now })) {
|
|
157
169
|
if (!creds.refreshToken || !creds.tokenEndpoint) {
|
|
158
170
|
throw new AuthUnavailableError(
|
|
@@ -169,7 +181,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
169
181
|
);
|
|
170
182
|
} catch (e) {
|
|
171
183
|
throw new AuthUnavailableError(
|
|
172
|
-
|
|
184
|
+
"couldn't refresh your Token of Trust session.",
|
|
173
185
|
{ reason: "refreshFailed", hint: developerCredentialRecoveryHint(creds) },
|
|
174
186
|
);
|
|
175
187
|
}
|
package/src/commands/clone.mjs
CHANGED
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
*
|
|
65
65
|
* Dependency-free (global fetch + `git` via child_process).
|
|
66
66
|
*/
|
|
67
|
+
import { recordRepositoryAuthority } from "../token-store.mjs";
|
|
67
68
|
import { execFile, execFileSync } from "node:child_process";
|
|
68
69
|
import { existsSync } from "node:fs";
|
|
69
70
|
import { join } from "node:path";
|
|
@@ -472,7 +473,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
472
473
|
if (!cloneDir) {
|
|
473
474
|
return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
|
|
474
475
|
}
|
|
475
|
-
const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, cloneDeps);
|
|
476
|
+
const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, { ...cloneDeps, mcpUrl: client.mcpUrl });
|
|
476
477
|
return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
|
|
477
478
|
}
|
|
478
479
|
|
|
@@ -502,6 +503,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
502
503
|
* @param {(s: string) => string} redact
|
|
503
504
|
* @param {{
|
|
504
505
|
* store?: boolean,
|
|
506
|
+
* mcpUrl?: string,
|
|
505
507
|
* git?: (cargs: string[]) => Promise<string>,
|
|
506
508
|
* gitSync?: (cargs: string[]) => string,
|
|
507
509
|
* installHelper?: (git: (cargs: string[]) => string, opts: { host?: string }) => boolean,
|
|
@@ -510,6 +512,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
510
512
|
*/
|
|
511
513
|
async function cloneRepo(gitRemote, dir, redact, {
|
|
512
514
|
store = true,
|
|
515
|
+
mcpUrl = undefined,
|
|
513
516
|
git = async (/** @type {string[]} */ cargs) => (await execFileP("git", cargs)).stdout.toString(),
|
|
514
517
|
gitSync = (/** @type {string[]} */ cargs) => execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" }),
|
|
515
518
|
installHelper = installForgeCredentialHelper,
|
|
@@ -551,6 +554,7 @@ async function cloneRepo(gitRemote, dir, redact, {
|
|
|
551
554
|
command: "clone", operation: "credential_helper_install", required: false, degraded: true,
|
|
552
555
|
});
|
|
553
556
|
}
|
|
557
|
+
if (mcpUrl) recordRepositoryAuthority(gitSync, mcpUrl);
|
|
554
558
|
if (record) {
|
|
555
559
|
// Not best-effort: without the record, the credential helper cannot tell what this
|
|
556
560
|
// checkout is, so every later fetch would fail with nothing to say why.
|
|
@@ -660,6 +664,7 @@ export async function clonePublicationSpaces(client, { tenant, dir, topology, re
|
|
|
660
664
|
const scrub = (/** @type {string} */ s) => (cred ? redact(s).split(cred.token).join("***") : redact(s));
|
|
661
665
|
const { head } = await cloneRepo(gitRemote, target, scrub, {
|
|
662
666
|
...deps,
|
|
667
|
+
mcpUrl: client.mcpUrl,
|
|
663
668
|
store: false,
|
|
664
669
|
record: (gitSync) => recordPublicationSpaceCheckout(gitSync, { tenant, spaceId: space.id, repo: space.source.repo }),
|
|
665
670
|
});
|
|
@@ -752,7 +757,7 @@ function serverReason(error) {
|
|
|
752
757
|
* (a server that dropped the `alias` key would otherwise hand back the caller's own store).
|
|
753
758
|
* The server reports no expiry — it reaps the reader token after ~24h — so the helper's TTL
|
|
754
759
|
* and erase→re-mint are what bound a cached copy.
|
|
755
|
-
* @param {{ callTool: (name: string, args: unknown) => Promise<any
|
|
760
|
+
* @param {{ callTool: (name: string, args: unknown) => Promise<any>, mcpUrl?: string }} client
|
|
756
761
|
* @param {{ target: string, repo: string }} alias
|
|
757
762
|
* @param {(s: string) => string} [redact]
|
|
758
763
|
* @returns {Promise<{ gitRemote: string, publicUrl: string }>}
|
|
@@ -797,7 +802,7 @@ export async function mintReadOnlyAlias(client, alias, redact = (s) => s) {
|
|
|
797
802
|
* `tot clone agency-kit [dir]`: mint the read-only credential, clone token-free through a
|
|
798
803
|
* one-shot `http.extraheader`, install the host-scoped credential helper, and record the
|
|
799
804
|
* checkout as a read-only kit clone so the helper re-mints it the same way.
|
|
800
|
-
* @param {{ callTool: (name: string, args: unknown) => Promise<any
|
|
805
|
+
* @param {{ callTool: (name: string, args: unknown) => Promise<any>, mcpUrl?: string }} client
|
|
801
806
|
* @param {{ alias: { target: string, repo: string }, cloneDir?: string|null, redact?: (s:string)=>string }} opts
|
|
802
807
|
* @param {Parameters<typeof cloneRepo>[3]} [deps]
|
|
803
808
|
*/
|
|
@@ -806,6 +811,7 @@ export async function cloneReadOnlyAlias(client, { alias, cloneDir = null, redac
|
|
|
806
811
|
if (!cloneDir) return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
|
|
807
812
|
const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, {
|
|
808
813
|
...deps,
|
|
814
|
+
mcpUrl: client.mcpUrl,
|
|
809
815
|
store: false,
|
|
810
816
|
record: (gitSync) => recordReadOnlyKitCheckout(gitSync, alias.target),
|
|
811
817
|
});
|
package/src/commands/dev.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
38
38
|
import { setTimeout as delay } from "node:timers/promises";
|
|
39
39
|
import { createMcpClient, CLI_VERSION, setRunnerVersion, versionStamp } from "../mcp.mjs";
|
|
40
40
|
import { establishSession } from "../auth.mjs";
|
|
41
|
-
import {
|
|
41
|
+
import { readSessionCredentials } from "../token-store.mjs";
|
|
42
42
|
import { emitObstacle } from "../obstacle.mjs";
|
|
43
43
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
44
44
|
import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
|
|
@@ -241,7 +241,7 @@ async function runNativePublic(workspace, args, ctx) {
|
|
|
241
241
|
* @returns {{TOT_DEV_ACTIVITY_TOKEN?: string, TOT_DEV_ACTIVITY_URL?: string}}
|
|
242
242
|
*/
|
|
243
243
|
export function activityBridgeEnv(env = process.env) {
|
|
244
|
-
const creds =
|
|
244
|
+
const creds = readSessionCredentials(env);
|
|
245
245
|
if (!creds?.activityToken || !creds?.activityUrl) return {};
|
|
246
246
|
return { TOT_DEV_ACTIVITY_TOKEN: creds.activityToken, TOT_DEV_ACTIVITY_URL: creds.activityUrl };
|
|
247
247
|
}
|
|
@@ -347,7 +347,7 @@ async function runNative(workspace, args, ctx) {
|
|
|
347
347
|
/**
|
|
348
348
|
* The env bootNative threads into the spawned runner. NEVER thread a real
|
|
349
349
|
* developer's activity-bridge credential into the zero-login --sample run:
|
|
350
|
-
* activityBridgeEnv() reads
|
|
350
|
+
* activityBridgeEnv() reads the selected authority/profile cache unconditionally, so a
|
|
351
351
|
* developer who's ever run `tot login --code` and then runs `tot dev --sample`
|
|
352
352
|
* (the "free taste"/demo path) would otherwise leak their real credential,
|
|
353
353
|
* posting fake sample-store activity to their real hosted /dev panel —
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
nativeWindowsDetail,
|
|
31
31
|
shouldAttemptDoctorAutoLogin,
|
|
32
32
|
} from "../platform.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { readSessionCredentials, isExpired } from "../token-store.mjs";
|
|
34
34
|
import { dockerAvailable, tryStartDocker, RENDERER_CACHE_ROOT, rendererArtifactComplete } from "./dev.mjs";
|
|
35
35
|
import { loginAndCache } from "./login.mjs";
|
|
36
36
|
|
|
@@ -140,7 +140,7 @@ export function collectChecks(_ctx, env = process.env) {
|
|
|
140
140
|
blocking: false,
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
const devCreds =
|
|
143
|
+
const devCreds = readSessionCredentials(env);
|
|
144
144
|
const devUsable = !!devCreds?.accessToken && (!isExpired(devCreds) || !!devCreds.refreshToken);
|
|
145
145
|
checks.push({
|
|
146
146
|
name: "auth identity",
|
|
@@ -24,7 +24,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
24
24
|
import { createMcpClient } from "../mcp.mjs";
|
|
25
25
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
26
26
|
import { readActivity, formatActivity } from "../activity-log.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { readSessionCredentials } from "../token-store.mjs";
|
|
28
28
|
import { fail } from "../errors.mjs";
|
|
29
29
|
import { formatDiagnostics, readDiagnostics, recordDiagnostic } from "../diagnostics.mjs";
|
|
30
30
|
|
|
@@ -154,7 +154,7 @@ export async function run(argv) {
|
|
|
154
154
|
// session. Read it BEFORE establishSession — a silent token refresh rewrites the
|
|
155
155
|
// credential file, so read the cached value first. Absent for a bare (non-invite)
|
|
156
156
|
// login; omitted from the payload then.
|
|
157
|
-
const traceId =
|
|
157
|
+
const traceId = readSessionCredentials(env)?.traceId || null;
|
|
158
158
|
|
|
159
159
|
// Preview — sending publishes to Token of Trust, so show exactly what goes out.
|
|
160
160
|
console.error("\nAbout to send to Token of Trust:");
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
import { readFileSync } from "node:fs";
|
|
51
51
|
import { execFileSync } from "node:child_process";
|
|
52
52
|
import { createMcpClient } from "../mcp.mjs";
|
|
53
|
-
import {
|
|
53
|
+
import { selectedAuthority, readSessionCredentials } from "../token-store.mjs";
|
|
54
|
+
import { AuthUnavailableError, establishSession } from "../auth.mjs";
|
|
54
55
|
import { checkoutTenant, mintPublicationSpace, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
|
|
55
56
|
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
56
57
|
import { detectContext } from "../context.mjs";
|
|
@@ -127,6 +128,13 @@ export async function mintOrCacheCredential({
|
|
|
127
128
|
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
128
129
|
mintReadOnly = mintReadOnlyAlias, mintSpace = mintPublicationSpace,
|
|
129
130
|
} = {}) {
|
|
131
|
+
let authority;
|
|
132
|
+
try { authority = selectedAuthority(env, { cwd }); }
|
|
133
|
+
catch { console.error("tot: this checkout has an invalid MCP authority. Run tot login --mcp <url> in this checkout."); return null; }
|
|
134
|
+
env = { ...env, TOT_SELECTED_MCP_URL: authority, MCP_BASE_URL: authority };
|
|
135
|
+
const authFailure = (e) => {
|
|
136
|
+
if (e instanceof AuthUnavailableError) console.error(`tot: ${e.message} ${e.hint || `Run tot login --mcp ${authority}.`}`);
|
|
137
|
+
};
|
|
130
138
|
const recorded = recordedCheckout(cwd);
|
|
131
139
|
if (recorded?.kind === "invalid") return null; // a half-recorded space mints nothing, never the store's
|
|
132
140
|
if (recorded?.kind === PUBLICATION_SPACE_KIND) {
|
|
@@ -135,7 +143,8 @@ export async function mintOrCacheCredential({
|
|
|
135
143
|
const client = createClient(baseUrl);
|
|
136
144
|
try {
|
|
137
145
|
await establish(client, { env });
|
|
138
|
-
} catch {
|
|
146
|
+
} catch (e) {
|
|
147
|
+
authFailure(e);
|
|
139
148
|
return null;
|
|
140
149
|
}
|
|
141
150
|
try {
|
|
@@ -147,6 +156,7 @@ export async function mintOrCacheCredential({
|
|
|
147
156
|
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
148
157
|
return cred ? { username: cred.username, password: cred.token } : null;
|
|
149
158
|
} catch (e) {
|
|
159
|
+
authFailure(e);
|
|
150
160
|
if (e instanceof CliError) console.error(formatError(e));
|
|
151
161
|
return null;
|
|
152
162
|
}
|
|
@@ -158,7 +168,7 @@ export async function mintOrCacheCredential({
|
|
|
158
168
|
if (!alias) return null; // a recorded target this CLI does not know — never guess a mint
|
|
159
169
|
const cachePath = kitCredentialCachePath(alias.target, env);
|
|
160
170
|
const cached = readCachedCredential(cachePath);
|
|
161
|
-
if (cached) return { username: cached.username, password: cached.password };
|
|
171
|
+
if (cached && readSessionCredentials(env)?.accessToken) return { username: cached.username, password: cached.password };
|
|
162
172
|
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
163
173
|
const client = createClient(baseUrl);
|
|
164
174
|
try {
|
|
@@ -174,6 +184,7 @@ export async function mintOrCacheCredential({
|
|
|
174
184
|
// report it on stderr so `git fetch` doesn't dead-end the developer in silence. A
|
|
175
185
|
// bare network/auth failure (not signed in, MCP unreachable) has no decision to
|
|
176
186
|
// report and stays silent, same as every other "nothing this helper can do" path.
|
|
187
|
+
authFailure(e);
|
|
177
188
|
if (e instanceof CliError) console.error(formatError(e));
|
|
178
189
|
return null;
|
|
179
190
|
}
|
|
@@ -227,7 +238,8 @@ export async function mintOrCacheCredential({
|
|
|
227
238
|
const client = createClient(baseUrl);
|
|
228
239
|
try {
|
|
229
240
|
await establish(client, { env });
|
|
230
|
-
} catch {
|
|
241
|
+
} catch (e) {
|
|
242
|
+
authFailure(e);
|
|
231
243
|
return null; // not signed in (or session unrefreshable) — nothing this helper can do
|
|
232
244
|
}
|
|
233
245
|
try {
|
|
@@ -295,7 +307,8 @@ export function forgetCredential({ env = process.env, cwd = process.cwd(), expec
|
|
|
295
307
|
const kit = kitCheckout(cwd);
|
|
296
308
|
if (!kit) return;
|
|
297
309
|
if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return;
|
|
298
|
-
|
|
310
|
+
const authority = selectedAuthority(env, { cwd });
|
|
311
|
+
dropCachedCredential(kitCredentialCachePath(kit.target, { ...env, TOT_SELECTED_MCP_URL: authority }));
|
|
299
312
|
} catch {
|
|
300
313
|
/* erase is advisory — never fail git over it */
|
|
301
314
|
}
|
package/src/commands/grants.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* binds this identity — the same ordering that fixes the "no stores" dead-end).
|
|
24
24
|
* Dependency-free.
|
|
25
25
|
*/
|
|
26
|
-
import {
|
|
26
|
+
import { readSessionCredentials } from "../token-store.mjs";
|
|
27
27
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
28
28
|
import { createMcpClient } from "../mcp.mjs";
|
|
29
29
|
import { storeListError, noStoresGuidance } from "./clone.mjs";
|
|
@@ -190,7 +190,7 @@ export async function run(argv, _ctx) {
|
|
|
190
190
|
|
|
191
191
|
// Same MCP-URL resolution as whoami: the cached session's origin wins, then env
|
|
192
192
|
// overrides, then the public default.
|
|
193
|
-
const creds =
|
|
193
|
+
const creds = readSessionCredentials(env);
|
|
194
194
|
const mcpUrl = creds?.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
195
195
|
const client = createMcpClient(mcpUrl);
|
|
196
196
|
|
package/src/commands/login.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
|
|
5
5
|
* opens the browser to the MCP's authorize page, the developer signs in with their
|
|
6
6
|
* ToT identity + approves, and the loopback catches the code and exchanges it for a
|
|
7
|
-
* token cached at
|
|
7
|
+
* token cached at the selected authority/profile cache. Every later command (`tot clone`,
|
|
8
8
|
* `tot submit`, …) then runs as that developer with NO re-auth.
|
|
9
9
|
*
|
|
10
10
|
* The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* Dependency-free (node built-ins via oauth.mjs).
|
|
21
21
|
*/
|
|
22
22
|
import { loginFlow, deviceLoginFlow, rendezvousLoginFlow, NoOpenerError } from "../oauth.mjs";
|
|
23
|
-
import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
|
|
23
|
+
import { defaultCredentialsPath, readCredentials, writeCredentials, normalizeAuthority, selectedAuthority, recordRepositoryAuthority } from "../token-store.mjs";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { detectContext } from "../context.mjs";
|
|
24
26
|
import { openBrowser } from "../open.mjs";
|
|
25
27
|
import { fail } from "../errors.mjs";
|
|
26
28
|
import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
|
|
@@ -28,8 +30,6 @@ import { loginFailureNext } from "../platform.mjs";
|
|
|
28
30
|
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
29
31
|
import { versionStamp } from "../mcp.mjs";
|
|
30
32
|
|
|
31
|
-
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
32
|
-
|
|
33
33
|
function parseArgs(argv) {
|
|
34
34
|
const a = {
|
|
35
35
|
mcp: null, device: false, code: null, help: false,
|
|
@@ -122,9 +122,12 @@ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot subm
|
|
|
122
122
|
* @returns {Promise<object>} the credentials written to disk.
|
|
123
123
|
*/
|
|
124
124
|
export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
125
|
+
mcpUrl = normalizeAuthority(mcpUrl);
|
|
126
|
+
const path = defaultCredentialsPath(env, mcpUrl);
|
|
127
|
+
let prior = readCredentials(path);
|
|
128
|
+
try { if (normalizeAuthority(prior?.mcpUrl) !== mcpUrl) prior = null; }
|
|
129
|
+
catch { prior = null; }
|
|
130
|
+
const clientId = prior?.clientId;
|
|
128
131
|
|
|
129
132
|
let creds;
|
|
130
133
|
if (device) {
|
|
@@ -198,7 +201,8 @@ export async function offerSignIn(mcpUrl, env = process.env, {
|
|
|
198
201
|
* @returns {Promise<object>} the credentials written to disk.
|
|
199
202
|
*/
|
|
200
203
|
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
|
|
201
|
-
|
|
204
|
+
mcpUrl = normalizeAuthority(mcpUrl);
|
|
205
|
+
const path = defaultCredentialsPath(env, mcpUrl);
|
|
202
206
|
const prior = readCredentials(path);
|
|
203
207
|
const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
|
|
204
208
|
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
@@ -225,7 +229,8 @@ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /*
|
|
|
225
229
|
* `TOT_PROFILE` (separate credential files — no carry to guard at all).
|
|
226
230
|
*/
|
|
227
231
|
export function mergeActivityBridge(prior, mcpUrl, creds) {
|
|
228
|
-
if (prior?.mcpUrl !== mcpUrl) return creds;
|
|
232
|
+
try { if (normalizeAuthority(prior?.mcpUrl) !== normalizeAuthority(mcpUrl)) return creds; }
|
|
233
|
+
catch { return creds; }
|
|
229
234
|
const priorHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
|
|
230
235
|
const newHint = redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "");
|
|
231
236
|
if (priorHint && newHint && priorHint !== newHint) return creds; // different identity → no bleed
|
|
@@ -260,16 +265,26 @@ export async function run(argv, _ctx) {
|
|
|
260
265
|
// a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
|
|
261
266
|
console.error(versionStamp());
|
|
262
267
|
|
|
263
|
-
const mcpUrl =
|
|
268
|
+
const mcpUrl = selectedAuthority(env, { mcpUrl: args.mcp });
|
|
269
|
+
const loginEnv = { ...env, TOT_SELECTED_MCP_URL: mcpUrl };
|
|
270
|
+
const bindCheckout = () => {
|
|
271
|
+
if (!args.mcp) return;
|
|
272
|
+
const git = (cargs) => execFileSync("git", cargs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
273
|
+
let kind = "";
|
|
274
|
+
try { kind = git(["config", "--local", "--get", "tot.checkoutKind"]).trim(); } catch { /* root store or loose directory */ }
|
|
275
|
+
if (detectContext().mode !== "checkout" && !["read-only-kit", "publication-space"].includes(kind)) return;
|
|
276
|
+
recordRepositoryAuthority(git, mcpUrl);
|
|
277
|
+
};
|
|
264
278
|
|
|
265
279
|
if (args.code) {
|
|
266
280
|
console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
|
|
267
281
|
try {
|
|
268
|
-
await redeemAndCache(mcpUrl, args.code,
|
|
269
|
-
cacheActivityBridge(
|
|
270
|
-
cacheTraceId(
|
|
271
|
-
cacheEmailHint(
|
|
272
|
-
|
|
282
|
+
await redeemAndCache(mcpUrl, args.code, loginEnv, { log: (m) => console.error(m) });
|
|
283
|
+
cacheActivityBridge(loginEnv, args.activityToken, args.activityUrl);
|
|
284
|
+
cacheTraceId(loginEnv, args.traceId);
|
|
285
|
+
cacheEmailHint(loginEnv, args.emailHint);
|
|
286
|
+
bindCheckout();
|
|
287
|
+
console.log("\n+ signed in. Session cached privately for this MCP authority.");
|
|
273
288
|
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
274
289
|
return 0;
|
|
275
290
|
} catch (e) {
|
|
@@ -285,9 +300,10 @@ export async function run(argv, _ctx) {
|
|
|
285
300
|
|
|
286
301
|
console.error(`~ signing in to Token of Trust (${mcpUrl})`);
|
|
287
302
|
try {
|
|
288
|
-
await loginAndCache(mcpUrl,
|
|
289
|
-
cacheTraceId(
|
|
290
|
-
|
|
303
|
+
await loginAndCache(mcpUrl, loginEnv, { log: (m) => console.error(m), device: args.device });
|
|
304
|
+
cacheTraceId(loginEnv, args.traceId);
|
|
305
|
+
bindCheckout();
|
|
306
|
+
console.log("\n+ signed in. Session cached privately for this MCP authority.");
|
|
291
307
|
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
292
308
|
return 0;
|
|
293
309
|
} catch (e) {
|
package/src/commands/logout.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tot logout` — sign this machine out of Token of Trust.
|
|
3
3
|
*
|
|
4
|
-
* Deletes the
|
|
4
|
+
* Deletes only the selected authority/profile session so the next command starts
|
|
5
5
|
* clean — the counterpart to `tot login` / `tot login --code`. Idempotent: logging
|
|
6
6
|
* out when already signed out is success, not an error.
|
|
7
7
|
*
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Dependency-free (node built-ins via token-store.mjs).
|
|
12
12
|
*/
|
|
13
|
-
import { defaultCredentialsPath, readCredentials, clearCredentials } from "../token-store.mjs";
|
|
13
|
+
import { defaultCredentialsPath, readCredentials, clearCredentials, selectedAuthority } from "../token-store.mjs";
|
|
14
14
|
|
|
15
15
|
const USAGE = `tot logout — sign this machine out of Token of Trust
|
|
16
16
|
|
|
17
|
-
tot logout
|
|
17
|
+
tot logout [--mcp <url>] clear only the selected authority/profile session
|
|
18
18
|
|
|
19
19
|
Idempotent — safe to run when already signed out. Clears only this machine's cache;
|
|
20
20
|
it does not revoke server-side. Run \`tot whoami\` to confirm you're signed out.`;
|
|
@@ -25,12 +25,14 @@ export async function run(argv) {
|
|
|
25
25
|
console.log(USAGE);
|
|
26
26
|
return 0;
|
|
27
27
|
}
|
|
28
|
-
const
|
|
28
|
+
const index = argv.indexOf("--mcp");
|
|
29
|
+
const authority = selectedAuthority(process.env, { mcpUrl: index < 0 ? undefined : argv[index + 1] });
|
|
30
|
+
const path = defaultCredentialsPath(process.env, authority);
|
|
29
31
|
// Report against the prior state so "signed out" vs "already clean" is honest.
|
|
30
32
|
const wasSignedIn = !!readCredentials(path);
|
|
31
33
|
const removed = clearCredentials(path);
|
|
32
34
|
if (wasSignedIn || removed) {
|
|
33
|
-
console.log(`+ signed out
|
|
35
|
+
console.log(`+ signed out of ${authority}.`);
|
|
34
36
|
console.log(" Run `tot login` (or `tot login --code <token>`) to sign in again.");
|
|
35
37
|
} else {
|
|
36
38
|
console.log("+ already signed out — no cached session to clear.");
|
package/src/commands/start.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
|
|
|
57
57
|
import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
|
|
58
58
|
import { startProgress } from "../progress.mjs";
|
|
59
59
|
import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
|
|
60
|
-
import {
|
|
60
|
+
import { readSessionCredentials } from "../token-store.mjs";
|
|
61
61
|
import { collectChecks } from "./doctor.mjs";
|
|
62
62
|
import { normalizeStores, storeListError, checkoutTenant, noStoresGuidance } from "./clone.mjs";
|
|
63
63
|
import {
|
|
@@ -173,7 +173,7 @@ export function decideStartMode({ sampleFlag, hasSession }) {
|
|
|
173
173
|
*/
|
|
174
174
|
export function cachedMcpUrl(env) {
|
|
175
175
|
try {
|
|
176
|
-
const creds =
|
|
176
|
+
const creds = readSessionCredentials(env);
|
|
177
177
|
return creds && typeof creds.mcpUrl === "string" && creds.mcpUrl ? creds.mcpUrl : null;
|
|
178
178
|
} catch {
|
|
179
179
|
return null;
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tot whoami` — who is `tot` signed in as, and what can they touch.
|
|
3
3
|
*
|
|
4
|
-
* Reads the cached session (
|
|
4
|
+
* Reads the cached session (the selected authority/profile cache). If a live token is present
|
|
5
5
|
* it best-effort asks the MCP for the stores this developer may act on (proving the
|
|
6
6
|
* token still works), degrading cleanly to the cached status if the MCP is
|
|
7
7
|
* unreachable or the developer isn't entitled yet.
|
|
8
8
|
*
|
|
9
9
|
* Dependency-free.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { readSessionCredentials, isExpired, activeProfile } from "../token-store.mjs";
|
|
12
12
|
import { establishSession } from "../auth.mjs";
|
|
13
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
14
14
|
import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
|
|
@@ -32,7 +32,7 @@ export function sessionStatus(creds, { now = Date.now() } = {}) {
|
|
|
32
32
|
/** @param {string[]} argv @param {any} _ctx */
|
|
33
33
|
export async function run(argv, _ctx) {
|
|
34
34
|
const env = process.env;
|
|
35
|
-
const creds =
|
|
35
|
+
const creds = readSessionCredentials(env);
|
|
36
36
|
const status = sessionStatus(creds);
|
|
37
37
|
|
|
38
38
|
const profile = activeProfile(env);
|
package/src/git-credential.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { join, dirname } from "node:path";
|
|
23
23
|
import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
24
|
-
import { readCredentials, writeCredentials } from "./token-store.mjs";
|
|
24
|
+
import { readCredentials, writeCredentials, selectedAuthority, profileSlug, readSessionCredentials } from "./token-store.mjs";
|
|
25
25
|
|
|
26
26
|
/** The git config value that routes credential requests through `tot` — the
|
|
27
27
|
* `!` tells git to run this as a shell command (git appends the operation,
|
|
@@ -129,10 +129,13 @@ export function kitCredentialCacheKey(target) {
|
|
|
129
129
|
return `kit-${createHash("sha256").update(`kit|${target}`).digest("hex").slice(0, 32)}`;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/** Kit credentials are isolated by authority, profile and the current OAuth grant. */
|
|
133
133
|
export function kitCredentialCachePath(target, env = process.env) {
|
|
134
134
|
const home = env.TOT_HOME || homedir();
|
|
135
|
-
|
|
135
|
+
const grant = readSessionCredentials(env)?.accessToken || "";
|
|
136
|
+
const scope = createHash("sha256")
|
|
137
|
+
.update(JSON.stringify([selectedAuthority(env), profileSlug(env), grant])).digest("hex");
|
|
138
|
+
return join(home, ".tot", "git-credentials", `${kitCredentialCacheKey(target)}-${scope}.json`);
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
/** Is a cached credential still trusted? A cache with no `mintedAt` is
|
package/src/oauth.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* The pieces are exported individually so the flow is unit-testable against a
|
|
23
23
|
* mocked `fetch` with no network. Dependency-free (node:http/crypto/timers + open.mjs).
|
|
24
24
|
*/
|
|
25
|
+
import { normalizeAuthority } from "./token-store.mjs";
|
|
25
26
|
import http from "node:http";
|
|
26
27
|
import crypto from "node:crypto";
|
|
27
28
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -45,6 +46,7 @@ async function oauthJson(url, init, operation, fetchImpl) {
|
|
|
45
46
|
try {
|
|
46
47
|
res = await fetchImpl(url, {
|
|
47
48
|
...init,
|
|
49
|
+
redirect: "error",
|
|
48
50
|
headers: { ...(init?.headers || {}), "X-Trace-Id": traceId },
|
|
49
51
|
});
|
|
50
52
|
} catch (cause) {
|
|
@@ -143,7 +145,7 @@ export function randomState() {
|
|
|
143
145
|
|
|
144
146
|
/** Fetch the Authorization Server metadata (RFC 8414). */
|
|
145
147
|
export async function discoverMetadata(mcpUrl, fetchImpl = fetch) {
|
|
146
|
-
const root =
|
|
148
|
+
const root = normalizeAuthority(mcpUrl);
|
|
147
149
|
const url = `${root}/.well-known/oauth-authorization-server`;
|
|
148
150
|
const { res, body: meta, context } = await oauthJson(
|
|
149
151
|
url, { headers: { Accept: "application/json" } }, "oauth_metadata", fetchImpl,
|
|
@@ -247,7 +249,7 @@ export function refreshAccessToken(tokenEndpoint, { refreshToken, clientId }, fe
|
|
|
247
249
|
export function credentialsFromToken({ mcpUrl, clientId, tokenEndpoint, scope = SCOPE, token, now = Date.now() }) {
|
|
248
250
|
const expiresIn = Number(token.expires_in) || 0;
|
|
249
251
|
return {
|
|
250
|
-
mcpUrl,
|
|
252
|
+
mcpUrl: normalizeAuthority(mcpUrl),
|
|
251
253
|
clientId,
|
|
252
254
|
tokenEndpoint,
|
|
253
255
|
scope: token.scope || scope,
|
package/src/obstacle.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ESM front door to the obstacle beacon (src/obstacle-beacon.cjs). The post-login
|
|
3
3
|
* failure paths — `pnpm-missing`, `install-failed`, `clone-failed` — run on a
|
|
4
4
|
* supported Node where the bridge credential is already cached, so they resolve
|
|
5
|
-
* {url, token} from
|
|
5
|
+
* {url, token} from the selected authority/profile cache (the same source activityBridgeEnv +
|
|
6
6
|
* the heartbeat use) rather than argv. `node-too-old` does NOT come through here:
|
|
7
7
|
* it fires from the ES5 launcher pre-login, where creds may not exist yet.
|
|
8
8
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import beaconCore from "./obstacle-beacon.cjs";
|
|
15
15
|
import { CLI_VERSION } from "./mcp.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import { readSessionCredentials } from "./token-store.mjs";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
|
|
@@ -24,7 +24,7 @@ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
|
|
|
24
24
|
*/
|
|
25
25
|
export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
|
|
26
26
|
try {
|
|
27
|
-
const creds =
|
|
27
|
+
const creds = readSessionCredentials(env);
|
|
28
28
|
const url = creds?.activityUrl;
|
|
29
29
|
const token = creds?.activityToken;
|
|
30
30
|
if (!url || !token) return;
|
package/src/token-store.mjs
CHANGED
|
@@ -1,54 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* ONE file, `~/.tot/credentials.json` (0600 in a 0700 dir), holding exactly what
|
|
6
|
-
* a later command needs to authenticate AND to silently refresh:
|
|
7
|
-
*
|
|
8
|
-
* { mcpUrl, clientId, tokenEndpoint, scope,
|
|
9
|
-
* accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
|
|
10
|
-
* activityToken, activityUrl, traceId, emailHint }
|
|
11
|
-
*
|
|
12
|
-
* We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
|
|
13
|
-
* re-registration, and `mcpUrl` so we never present a token minted for one MCP
|
|
14
|
-
* to a different one. `activityToken`/`activityUrl` are a SEPARATE credential
|
|
15
|
-
* (storefront-issued, not MCP OAuth) that `tot login --code` caches when the
|
|
16
|
-
* invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
|
|
17
|
-
* commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
|
|
18
|
-
* monorepo, native standalone, Docker) threads in so the local dev-loop process
|
|
19
|
-
* can report file saves to the developer's hosted /dev panel. `traceId` is the
|
|
20
|
-
* storefront-minted invite→terminal→problem trace id (cached by `tot login`'s
|
|
21
|
-
* `--trace-id` flag, see commands/login.mjs `cacheTraceId`); `tot feedback`
|
|
22
|
-
* stamps it onto a problem report so triage can join it to the invite + the live
|
|
23
|
-
* heartbeat session. `emailHint` is masked, display-only recovery context from
|
|
24
|
-
* the setup command, used to orient cockpit/login after a local credential
|
|
25
|
-
* expires without persisting a raw address.
|
|
26
|
-
*
|
|
27
|
-
* The read/write is a plain JSON pass-through (no schema) — new fields like
|
|
28
|
-
* `traceId` ride along additively; readers simply pick the keys they need.
|
|
29
|
-
* Dependency-free (node:fs/os/path).
|
|
30
|
-
*
|
|
31
|
-
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
|
32
|
-
*
|
|
33
|
-
* PROFILES (`TOT_PROFILE`) — OPTIONAL, for parallel work. Almost every developer
|
|
34
|
-
* leaves this UNSET and uses the single default `credentials.json` — that path is
|
|
35
|
-
* unchanged and is the norm. It exists only when you want MORE THAN ONE identity
|
|
36
|
-
* live at once (a staff `@tokenoftrust.com` sign-in and a plain developer one, or
|
|
37
|
-
* many parallel test identities): export a profile per shell and each gets its OWN
|
|
38
|
-
* credential file under the same `~/.tot` (the renderer cache, last-tenant, etc.
|
|
39
|
-
* stay shared — only the identity splits). The value is an OPAQUE label — any
|
|
40
|
-
* string works, so `export TOT_PROFILE=$(uuidgen)` per terminal/test is fine:
|
|
41
|
-
* - a clean short token (`staff`, `dev`, `test-7`) is used verbatim for a
|
|
42
|
-
* readable `credentials.<profile>.json`;
|
|
43
|
-
* - any other value (symbols, uppercase, long) is HASHED to a stable, safe
|
|
44
|
-
* `credentials.h<hash>.json` — so an arbitrary opaque id can never collide
|
|
45
|
-
* with another or escape `~/.tot`, while `tot whoami` still shows what you set.
|
|
2
|
+
* OAuth sessions are private files keyed by MCP authority and identity profile.
|
|
3
|
+
* Endpoint paths share an authority; QA and production retain separate sessions.
|
|
4
|
+
* TOT_HOME selects a private home and TOT_PROFILE selects an independent identity.
|
|
46
5
|
*/
|
|
47
6
|
import {
|
|
48
|
-
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync,
|
|
7
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync, linkSync, unlinkSync,
|
|
49
8
|
} from "node:fs";
|
|
50
9
|
import { homedir } from "node:os";
|
|
51
|
-
import { createHash } from "node:crypto";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
52
12
|
import { join, dirname } from "node:path";
|
|
53
13
|
|
|
54
14
|
/** A short token safe to drop straight into a filename (readable profiles). */
|
|
@@ -78,24 +38,134 @@ export function profileSlug(env = process.env) {
|
|
|
78
38
|
return `h${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
|
|
79
39
|
}
|
|
80
40
|
|
|
81
|
-
|
|
82
|
-
export
|
|
41
|
+
export const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
42
|
+
export const MCP_AUTHORITY_KEY = "tot.mcp-url";
|
|
43
|
+
|
|
44
|
+
/** Identity boundary shared by every MCP endpoint on one origin. */
|
|
45
|
+
export function normalizeAuthority(value) {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(value);
|
|
48
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
49
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
50
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local))) throw new Error();
|
|
51
|
+
return url.origin;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("invalid MCP authority — use an HTTPS URL without credentials, query or fragment.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Local checkout provenance, never inferred from a shared forge hostname. */
|
|
58
|
+
export function repositoryAuthority(cwd = process.cwd()) {
|
|
59
|
+
let value;
|
|
60
|
+
try {
|
|
61
|
+
value = execFileSync("git", ["-C", cwd, "config", "--local", "--get", MCP_AUTHORITY_KEY], {
|
|
62
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
}).trim();
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
return value ? normalizeAuthority(value) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Explicit request > checkout provenance > shell configuration > production.
|
|
70
|
+
* @param {NodeJS.ProcessEnv} env
|
|
71
|
+
* @param {{mcpUrl?: string|null, cwd?: string}} opts
|
|
72
|
+
*/
|
|
73
|
+
export function selectedAuthority(env = process.env, { mcpUrl, cwd = process.cwd() } = {}) {
|
|
74
|
+
return normalizeAuthority(mcpUrl || env.TOT_SELECTED_MCP_URL || repositoryAuthority(cwd) || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Record the authority used to authenticate this checkout. No credentials land in git. */
|
|
78
|
+
export function recordRepositoryAuthority(git, mcpUrl) {
|
|
79
|
+
git(["config", "--local", MCP_AUTHORITY_KEY, normalizeAuthority(mcpUrl)]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function credentialsPath(env, authority) {
|
|
83
|
+
const home = env.TOT_HOME || homedir();
|
|
84
|
+
const profile = profileSlug(env);
|
|
85
|
+
const key = createHash("sha256").update(normalizeAuthority(authority)).digest("hex");
|
|
86
|
+
return join(home, ".tot", `credentials${profile ? `.${profile}` : ""}.${key}.json`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cache failures expose fixed diagnostics, never raw filesystem text or causes. */
|
|
90
|
+
class CredentialCacheError extends Error {
|
|
91
|
+
constructor(conflict = false) {
|
|
92
|
+
super(conflict
|
|
93
|
+
? "credential cache authority conflict — refusing to replace or clear another authority's session."
|
|
94
|
+
: "could not access the local credential cache — check permissions and retry.");
|
|
95
|
+
this.code = conflict ? "credential_cache_authority_conflict" : "credential_cache_unavailable";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Convert one valid unscoped session once, without overwriting another session. */
|
|
100
|
+
function convertCredentials(env) {
|
|
83
101
|
const home = env.TOT_HOME || homedir();
|
|
84
|
-
const
|
|
85
|
-
|
|
102
|
+
const profile = profileSlug(env);
|
|
103
|
+
const oldPath = join(home, ".tot", profile ? `credentials.${profile}.json` : "credentials.json");
|
|
104
|
+
const prior = readCredentials(oldPath);
|
|
105
|
+
if (typeof prior?.accessToken !== "string" || !prior.accessToken || typeof prior.mcpUrl !== "string") return;
|
|
106
|
+
const target = credentialsPath(env, prior.mcpUrl);
|
|
107
|
+
try {
|
|
108
|
+
// link is atomic and refuses an existing destination. Unlink completes the move.
|
|
109
|
+
linkSync(oldPath, target);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// A current session for this same authority/profile supersedes the unscoped copy.
|
|
112
|
+
// Retire that duplicate so logout cannot restore it on the next cache read.
|
|
113
|
+
if (error?.code === "ENOENT") return; // another reader already retired the source
|
|
114
|
+
if (error?.code !== "EEXIST") throw error;
|
|
115
|
+
const current = readCredentials(target);
|
|
116
|
+
let currentAuthority = null;
|
|
117
|
+
try { currentAuthority = normalizeAuthority(current?.mcpUrl); } catch { /* corrupt destination */ }
|
|
118
|
+
if (!currentAuthority || typeof current?.accessToken !== "string" || !current.accessToken) {
|
|
119
|
+
writeCredentials(target, prior);
|
|
120
|
+
} else if (currentAuthority !== normalizeAuthority(prior.mcpUrl)) {
|
|
121
|
+
throw new CredentialCacheError(true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
chmodSync(dirname(target), 0o700);
|
|
125
|
+
chmodSync(target, 0o600);
|
|
126
|
+
// Another first reader may have retired this source after our EEXIST result.
|
|
127
|
+
try { unlinkSync(oldPath); }
|
|
128
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Absolute path to the selected authority/profile session.
|
|
133
|
+
* @param {NodeJS.ProcessEnv} env
|
|
134
|
+
* @param {string|undefined} mcpUrl
|
|
135
|
+
*/
|
|
136
|
+
export function defaultCredentialsPath(env = process.env, mcpUrl = undefined) {
|
|
137
|
+
try { convertCredentials(env); }
|
|
138
|
+
catch (error) { throw error instanceof CredentialCacheError ? error : new CredentialCacheError(); }
|
|
139
|
+
return credentialsPath(env, selectedAuthority(env, { mcpUrl }));
|
|
86
140
|
}
|
|
87
141
|
|
|
88
142
|
/**
|
|
89
|
-
* Read
|
|
90
|
-
*
|
|
143
|
+
* Read a session only when its issuer matches the selected destination.
|
|
144
|
+
* @param {NodeJS.ProcessEnv} env
|
|
145
|
+
* @param {string|undefined} mcpUrl
|
|
146
|
+
*/
|
|
147
|
+
export function readSessionCredentials(env = process.env, mcpUrl = undefined) {
|
|
148
|
+
const authority = selectedAuthority(env, { mcpUrl });
|
|
149
|
+
const creds = readCredentials(defaultCredentialsPath(env, authority));
|
|
150
|
+
try { return creds && normalizeAuthority(creds.mcpUrl) === authority ? creds : null; }
|
|
151
|
+
catch { return null; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read + parse credentials, or null if absent/malformed. Operational read errors
|
|
156
|
+
* refuse so an unreadable session cannot be mistaken for a missing session.
|
|
91
157
|
*/
|
|
92
158
|
export function readCredentials(filePath) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
159
|
+
let text;
|
|
160
|
+
try { text = readFileSync(filePath, "utf8"); }
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error?.code === "ENOENT") return null;
|
|
163
|
+
throw new CredentialCacheError();
|
|
98
164
|
}
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(text);
|
|
167
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
168
|
+
} catch { return null; }
|
|
99
169
|
}
|
|
100
170
|
|
|
101
171
|
/**
|
|
@@ -106,10 +176,15 @@ export function readCredentials(filePath) {
|
|
|
106
176
|
*/
|
|
107
177
|
export function writeCredentials(filePath, creds) {
|
|
108
178
|
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
179
|
+
chmodSync(dirname(filePath), 0o700);
|
|
180
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
183
|
+
renameSync(tmp, filePath);
|
|
184
|
+
chmodSync(filePath, 0o600);
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(tmp, { force: true });
|
|
187
|
+
}
|
|
113
188
|
}
|
|
114
189
|
|
|
115
190
|
/**
|
|
@@ -126,7 +201,7 @@ export function isExpired(creds, { now = Date.now(), skewMs = 60_000 } = {}) {
|
|
|
126
201
|
/**
|
|
127
202
|
* Delete the cached session (the whole "signed in" state), idempotently. Returns
|
|
128
203
|
* true if a credential file was actually removed, false if there was nothing to
|
|
129
|
-
* remove.
|
|
204
|
+
* remove. An unreadable/locked cache refuses rather than claiming sign-out. Local-only: this clears
|
|
130
205
|
* the machine's cache; it does NOT revoke the grant server-side.
|
|
131
206
|
*/
|
|
132
207
|
export function clearCredentials(filePath) {
|
|
@@ -134,7 +209,7 @@ export function clearCredentials(filePath) {
|
|
|
134
209
|
try {
|
|
135
210
|
rmSync(filePath, { force: true });
|
|
136
211
|
} catch {
|
|
137
|
-
|
|
212
|
+
throw new Error("could not clear the selected session cache — check its permissions, then retry.");
|
|
138
213
|
}
|
|
139
214
|
return existed;
|
|
140
215
|
}
|