@retasc/cli 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +38 -0
- package/dist/commands/billing.js +1 -1
- package/dist/commands/bind.js +25 -3
- package/dist/commands/doctor.js +93 -44
- package/dist/index.js +18 -5
- package/dist/lib/binding.js +133 -14
- package/dist/lib/claim.js +8 -1
- package/dist/lib/keystore.js +25 -8
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -33,11 +33,49 @@ function client() {
|
|
|
33
33
|
c.setAuth(cfg.token);
|
|
34
34
|
return c;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Read a caught backend error into the parts a command wants to show (RTSC-261).
|
|
38
|
+
*
|
|
39
|
+
* `code` is machine-readable, so a command branches on `code === "EXPIRED"`
|
|
40
|
+
* instead of regex-matching prose that any copy edit would break. `hint` is the
|
|
41
|
+
* next step, kept separate so callers can dim or indent it.
|
|
42
|
+
*
|
|
43
|
+
* Both are optional because the sweep is incremental: a backend site still
|
|
44
|
+
* throwing a plain Error (or an OLD deployment this CLI is pointed at) yields
|
|
45
|
+
* `{message}` alone via the same first-line/strip-prefix cleanup used before.
|
|
46
|
+
* Callers must therefore keep any existing string fallback rather than assuming
|
|
47
|
+
* `code` is present.
|
|
48
|
+
*/
|
|
49
|
+
export function formatError(e) {
|
|
50
|
+
const data = e?.data;
|
|
51
|
+
if (data && typeof data === "object" && typeof data.message === "string") {
|
|
52
|
+
// Type-guard EVERY field, not just `message`: a foreign error carrying
|
|
53
|
+
// `{message: "x", code: {…}}` would otherwise print `✗ [object Object]: x`.
|
|
54
|
+
// And strip control characters — these strings go to `console.error`, so a
|
|
55
|
+
// future converted site that interpolates user data (issue ids, org names)
|
|
56
|
+
// must not be able to smuggle ANSI escapes into the terminal. Mirrors
|
|
57
|
+
// `sanitize` in convex/lib/userError.ts; the two are one wire contract.
|
|
58
|
+
const clean = (v) =>
|
|
59
|
+
// eslint-disable-next-line no-control-regex
|
|
60
|
+
typeof v === "string" ? v.replace(/[\x00-\x1f\x7f]/g, " ").trim() : undefined;
|
|
61
|
+
return { code: clean(data.code), message: clean(data.message), hint: clean(data.hint) };
|
|
62
|
+
}
|
|
63
|
+
const message = String(e?.message ?? e)
|
|
64
|
+
.split("\n")[0]
|
|
65
|
+
.replace(/^.*?Uncaught Error:\s*/, "")
|
|
66
|
+
.trim();
|
|
67
|
+
return { message };
|
|
68
|
+
}
|
|
36
69
|
// Does this error mean "the access token is missing/expired", i.e. a refresh
|
|
37
70
|
// might fix it? The access-token JWT lives ~1h, so any long-lived login trips
|
|
38
71
|
// this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
|
|
39
72
|
// (requireUser), and the Convex platform rejects a stale JWT with "Could not
|
|
40
73
|
// verify OIDC token"/"Unauthenticated". Match either, case-insensitively.
|
|
74
|
+
// RTSC-261 deliberately does NOT touch this. It gates the refresh-and-retry
|
|
75
|
+
// loop, no site converted here throws an auth code, and `formatError` truncates
|
|
76
|
+
// to the first line — so routing it through there would add real risk (a missed
|
|
77
|
+
// match means a spurious device-flow re-login) for no gain today. It gets a
|
|
78
|
+
// `code === "UNAUTHENTICATED"` fast path in the PR that converts lib/auth.ts.
|
|
41
79
|
export function isAuthError(e) {
|
|
42
80
|
const msg = String(e?.message ?? e);
|
|
43
81
|
return /unauthenticated|could not verify oidc|oidc token/i.test(msg);
|
package/dist/commands/billing.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function billingAction(opts) {
|
|
|
68
68
|
const c = charges;
|
|
69
69
|
console.log(`\nBilling — ${orgLabel}\n`);
|
|
70
70
|
console.log("SUBSCRIPTION");
|
|
71
|
-
row("Status",
|
|
71
|
+
row("Status", `${s.status}${s.gated ? " (gated)" : ""}`);
|
|
72
72
|
row("Subscription", s.subscriptionId ?? "none yet");
|
|
73
73
|
if (s.spendingCapUsd != null) {
|
|
74
74
|
row("Budget", `${usd(s.spendingCapUsd)}${s.capPeriod ? ` / ${s.capPeriod.replace("per_", "")}` : ""}`);
|
package/dist/commands/bind.js
CHANGED
|
@@ -4,7 +4,7 @@ import { api } from "../api.js";
|
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { installMarker } from "./mcp.js";
|
|
6
6
|
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
7
|
-
import { setBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
7
|
+
import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
8
8
|
function isInteractive() {
|
|
9
9
|
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
10
10
|
}
|
|
@@ -42,8 +42,21 @@ export async function bindAction(opts) {
|
|
|
42
42
|
const cfg = loadConfig();
|
|
43
43
|
const cwd = process.cwd();
|
|
44
44
|
// --- loud on re-bind -------------------------------------------------------
|
|
45
|
+
// markerOnly (a cloned repo's committed marker, or an entry the CLI can't
|
|
46
|
+
// read a key from) never gates: there is no usable binding to "replace", and
|
|
47
|
+
// minting a key under the existing marker id is exactly what bind is FOR.
|
|
45
48
|
const existing = readLocalBinding(cwd);
|
|
46
|
-
if (existing) {
|
|
49
|
+
if (existing && !existing.markerOnly) {
|
|
50
|
+
// Idempotent converge (RTSC-262): re-running bind with the SAME target is
|
|
51
|
+
// success, not a refusal — a provisioning script must be able to run
|
|
52
|
+
// `retasc bind --org-id X --project-id Y` repeatedly without churning keys.
|
|
53
|
+
if (existing.workspaceId && opts.orgId && opts.projectId) {
|
|
54
|
+
const cur = getBinding(existing.workspaceId);
|
|
55
|
+
if (cur && cur.orgId === opts.orgId && cur.projectId === opts.projectId) {
|
|
56
|
+
console.log(`Already bound to the requested org/project (${cur.prefix ?? cur.projectId}). Nothing to do.`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
47
60
|
let where = "an existing Retasc binding";
|
|
48
61
|
try {
|
|
49
62
|
const b = await resolveBinding(existing.url || cfg.mcpUrl, existing.key);
|
|
@@ -54,7 +67,16 @@ export async function bindAction(opts) {
|
|
|
54
67
|
}
|
|
55
68
|
console.log(`This folder is already bound to ${where}.`);
|
|
56
69
|
if (!(await confirm("Replace it?", opts.yes))) {
|
|
57
|
-
|
|
70
|
+
// RTSC-262: with no TTY, confirm() answers "no" on its own — and the
|
|
71
|
+
// widened binding lookup makes an existing binding the COMMON case. A
|
|
72
|
+
// provisioning script must not be told success (exit 0) for a no-op.
|
|
73
|
+
if (!isInteractive()) {
|
|
74
|
+
console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.log("Left unchanged.");
|
|
79
|
+
}
|
|
58
80
|
return;
|
|
59
81
|
}
|
|
60
82
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,49 +1,76 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
1
|
import { loadConfig } from "../config.js";
|
|
5
|
-
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
2
|
+
import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
|
|
6
3
|
import { getBinding } from "../lib/keystore.js";
|
|
7
4
|
// RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
|
|
8
|
-
// safely bound.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// safely bound. The question a human actually has is "which org/project does
|
|
6
|
+
// this folder talk to?", so the healthy answer is ONE line naming them.
|
|
7
|
+
//
|
|
8
|
+
// RTSC-262: a binding is found in either legal per-folder location —
|
|
9
|
+
// ./.mcp.json or Claude Code's local scope (the DEFAULT `claude mcp add`
|
|
10
|
+
// writes). Reading only the former made doctor cry "not bound" about a working
|
|
11
|
+
// binding, which teaches people to distrust everything else it says. When both
|
|
12
|
+
// exist, we report the claude-local one (what Claude Code actually runs;
|
|
13
|
+
// local > project) and warn if a shadowed folder marker disagrees. The
|
|
14
|
+
// illegal-global check is presence-based and never prints an all-clear it
|
|
15
|
+
// can't prove (unreadable config ≠ no global server).
|
|
11
16
|
const ok = (m) => console.log(` ✓ ${m}`);
|
|
12
17
|
const warn = (m) => console.log(` ! ${m}`);
|
|
13
18
|
const bad = (m) => console.log(` ✗ ${m}`);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const path = join(homedir(), ".claude.json");
|
|
19
|
-
if (!existsSync(path))
|
|
20
|
-
return false;
|
|
21
|
-
try {
|
|
22
|
-
const doc = JSON.parse(readFileSync(path, "utf8"));
|
|
23
|
-
return Boolean(doc?.mcpServers?.retasc);
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
19
|
+
// Server- and config-derived strings end up inside doctor's verdict lines; a
|
|
20
|
+
// hostile endpoint (reachable via a config-supplied legacy url) must not be
|
|
21
|
+
// able to smuggle ANSI escapes into the very output people trust for ✓/✗.
|
|
22
|
+
const clean = (s) => String(s).replace(/[\x00-\x1f\x7f]/g, " ");
|
|
29
23
|
export async function doctorAction() {
|
|
30
24
|
const cfg = loadConfig();
|
|
31
25
|
const cwd = process.cwd();
|
|
32
26
|
console.log(`Retasc workspace check — ${cwd}\n`);
|
|
33
|
-
//
|
|
27
|
+
// An override redirects BOTH the folder lookup and the global safety check,
|
|
28
|
+
// so its presence must be visible, not silent (it's a test hook, but nothing
|
|
29
|
+
// stops an .envrc or CI env from setting it).
|
|
30
|
+
if (process.env.RETASC_CLAUDE_CONFIG) {
|
|
31
|
+
warn(`RETASC_CLAUDE_CONFIG is set — inspecting ${claudeConfigPath()} instead of ~/.claude.json.`);
|
|
32
|
+
}
|
|
33
|
+
// 1) Which org/project does this folder talk to?
|
|
34
|
+
let networkDown = false;
|
|
34
35
|
const local = readLocalBinding(cwd);
|
|
35
36
|
if (!local) {
|
|
36
|
-
bad("not bound — no Retasc MCP server
|
|
37
|
+
bad("not bound — no Retasc MCP server for this folder. Run `retasc bind`.");
|
|
37
38
|
}
|
|
38
39
|
else if (local.markerOnly) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
if (local.workspaceId) {
|
|
41
|
+
// RTSC-92: marker present (e.g. a cloned repo) but no key in this
|
|
42
|
+
// machine's keystore. Safe — the agent gets no tools — but must bind.
|
|
43
|
+
warn(`workspace marker present (${clean(local.workspaceId)}) but no key in your keystore.`);
|
|
44
|
+
bad("not usable yet on this machine — run `retasc bind` to mint your own key.");
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// An entry is registered (and the runtime will use it) but carries no
|
|
48
|
+
// key material the CLI recognizes — likely hand-edited.
|
|
49
|
+
warn(`a Retasc server is registered for this folder (${local.source} scope) but the CLI can't read a key from it.`);
|
|
50
|
+
bad("not usable by the CLI — re-run `retasc bind` to rewrite it.");
|
|
51
|
+
}
|
|
43
52
|
}
|
|
44
53
|
else {
|
|
54
|
+
// The healthy path is a single line: bound, and to WHAT. Server-resolved so
|
|
55
|
+
// it's the same answer the agent gets, not a local guess.
|
|
56
|
+
try {
|
|
57
|
+
const b = await resolveBinding(local.url || cfg.mcpUrl, local.key);
|
|
58
|
+
ok(`bound — org "${clean(b.org.name)}" / project ${clean(b.project.prefix)} (${clean(b.project.name)}).`);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const msg = clean(e?.message ?? e);
|
|
62
|
+
if (isNetworkError(e)) {
|
|
63
|
+
// The binding EXISTS; we just can't verify it. Don't send the user off
|
|
64
|
+
// to re-bind over a dead wifi link or a server having a bad minute.
|
|
65
|
+
networkDown = true;
|
|
66
|
+
warn(`binding present (${local.source} scope) but the server didn't answer: ${msg}.`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Caveats below the headline — each is a real risk, none is the common case.
|
|
45
73
|
if (local.workspaceId) {
|
|
46
|
-
ok(`bound (secret-free marker → home keystore, ${local.workspaceId}).`);
|
|
47
74
|
// The id resolved, but if it was bound at a different folder, this marker
|
|
48
75
|
// may have reused someone else's id — surface it rather than silently use it.
|
|
49
76
|
const entry = getBinding(local.workspaceId);
|
|
@@ -54,25 +81,47 @@ export async function doctorAction() {
|
|
|
54
81
|
}
|
|
55
82
|
}
|
|
56
83
|
else if (local.legacy) {
|
|
57
|
-
|
|
58
|
-
warn("the key is stored IN this folder. Run `retasc bind` to move it to the keystore (secret-free marker).");
|
|
59
|
-
}
|
|
60
|
-
// 2) Does the key resolve to an org/project?
|
|
61
|
-
try {
|
|
62
|
-
const b = await resolveBinding(local.url || cfg.mcpUrl, local.key);
|
|
63
|
-
ok(`key valid → org "${b.org.name}" / project ${b.project.prefix} (${b.project.name}).`);
|
|
84
|
+
warn("the key is stored inline in the config. Run `retasc bind` to move it to the keystore.");
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
// Bound in BOTH places with different identities: Claude Code uses the
|
|
87
|
+
// local-scope entry, so a stale committed ./.mcp.json marker would quietly
|
|
88
|
+
// mislead anyone reading the repo. Say which one wins. (Re-binding does NOT
|
|
89
|
+
// clear the folder marker — bind writes local scope — so the advice is to
|
|
90
|
+
// fix the marker itself.)
|
|
91
|
+
const shadowed = readShadowedBinding(cwd);
|
|
92
|
+
if (shadowed && !sameIdentity(local, shadowed)) {
|
|
93
|
+
warn(`./.mcp.json ALSO carries a Retasc binding, and it differs from the one in use.\n` +
|
|
94
|
+
` Claude Code runs the local-scope entry (local > project); the folder marker is shadowed.\n` +
|
|
95
|
+
` Remove the stale retasc entry from ./.mcp.json, or align it with the binding in use.`);
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
// 2) The safety check: any illegal machine-global Retasc server?
|
|
99
|
+
const global = readGlobalBinding();
|
|
100
|
+
if (global.status === "none") {
|
|
101
|
+
ok("no illegal global Retasc server.");
|
|
102
|
+
}
|
|
103
|
+
else if (global.status === "unreadable") {
|
|
104
|
+
warn("could not parse Claude Code's config — cannot verify there is no global Retasc server\n" +
|
|
105
|
+
" (a local-scope binding for this folder would be invisible too).");
|
|
74
106
|
}
|
|
75
107
|
else {
|
|
76
|
-
|
|
108
|
+
// Name the org it points at when we can: "leaks across orgs" is abstract,
|
|
109
|
+
// "every unbound folder gets org X" is what makes someone act. Presence
|
|
110
|
+
// alone is the finding — a keyless entry is still an illegal registration —
|
|
111
|
+
// and if the network already failed above, don't burn another timeout.
|
|
112
|
+
let points = "";
|
|
113
|
+
if (global.binding?.key && !networkDown) {
|
|
114
|
+
try {
|
|
115
|
+
const b = await resolveBinding(global.binding.url || cfg.mcpUrl, global.binding.key);
|
|
116
|
+
points = ` It points at org "${clean(b.org.name)}".`;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* unresolvable (revoked/offline) — the registration is still illegal. */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
bad(`GLOBAL Retasc server registered (Claude Code config, top level).${points}\n` +
|
|
123
|
+
` It captures every folder on this machine that has no binding of its\n` +
|
|
124
|
+
` own, so issues can land in the wrong project.\n` +
|
|
125
|
+
` Fix: claude mcp remove -s user retasc`);
|
|
77
126
|
}
|
|
78
127
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,11 +10,11 @@ import { claimAction } from "./commands/claim.js";
|
|
|
10
10
|
import { bindAction } from "./commands/bind.js";
|
|
11
11
|
import { doctorAction } from "./commands/doctor.js";
|
|
12
12
|
import { billingAction } from "./commands/billing.js";
|
|
13
|
-
import { readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
13
|
+
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
14
14
|
import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
15
15
|
import { runProxy } from "./proxy.js";
|
|
16
16
|
import { deviceLogin } from "./auth.js";
|
|
17
|
-
import { api } from "./api.js";
|
|
17
|
+
import { api, formatError } from "./api.js";
|
|
18
18
|
// Single source of truth for the version: read package.json at runtime from the
|
|
19
19
|
// compiled file's location (dist/index.js -> ../package.json). A JSON import won't
|
|
20
20
|
// work here — tsconfig has rootDir "src", so importing ../package.json is outside
|
|
@@ -31,9 +31,15 @@ function requireLogin() {
|
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
// RTSC-261: prefer the structured payload, and print `hint` on its own dimmed
|
|
35
|
+
// line — the whole reason it's a separate field is that the fix shouldn't be
|
|
36
|
+
// buried in the middle of the diagnosis. `formatError` falls back to the old
|
|
37
|
+
// string cleanup, so unconverted backend sites print exactly as they did.
|
|
34
38
|
function fail(e) {
|
|
35
|
-
const
|
|
36
|
-
console.error(`✗ ${
|
|
39
|
+
const { code, message, hint } = formatError(e);
|
|
40
|
+
console.error(`✗ ${code ? `${code}: ` : ""}${message}`);
|
|
41
|
+
if (hint)
|
|
42
|
+
console.error(` → ${hint}`);
|
|
37
43
|
process.exit(1);
|
|
38
44
|
}
|
|
39
45
|
// --- auth ------------------------------------------------------------------
|
|
@@ -80,7 +86,14 @@ program
|
|
|
80
86
|
console.log(` as ${b.member.name}${b.member.session ? ` · session ${b.member.session}` : ""}\n`);
|
|
81
87
|
}
|
|
82
88
|
catch (e) {
|
|
83
|
-
|
|
89
|
+
// Strip control chars — server-derived text must not smuggle ANSI
|
|
90
|
+
// escapes into trusted output (same rule as doctor's clean()).
|
|
91
|
+
const msg = String(e?.message ?? e).replace(/[\x00-\x1f\x7f]/g, " ");
|
|
92
|
+
// Unreachable ≠ rejected: don't imply a bad key over a dead network.
|
|
93
|
+
if (isNetworkError(e))
|
|
94
|
+
console.log(`This folder → binding present, but the server didn't answer: ${msg}\n`);
|
|
95
|
+
else
|
|
96
|
+
console.log(`This folder → bound, but the key did not resolve: ${msg}\n`);
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
else {
|
package/dist/lib/binding.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
1
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { getBinding } from "./keystore.js";
|
|
4
|
-
/**
|
|
5
|
-
export function
|
|
6
|
-
|
|
5
|
+
/** Claude Code's config file. RETASC_CLAUDE_CONFIG overrides it (tests). */
|
|
6
|
+
export function claudeConfigPath() {
|
|
7
|
+
return process.env.RETASC_CLAUDE_CONFIG || join(homedir(), ".claude.json");
|
|
8
|
+
}
|
|
9
|
+
// "absent" and "unparseable" are different facts: an absent config proves no
|
|
10
|
+
// global server; a corrupt one proves nothing, and a safety check must never
|
|
11
|
+
// print an all-clear off a failed read (~/.claude.json is rewritten constantly,
|
|
12
|
+
// so a truncated write is a realistic state, not a corner case).
|
|
13
|
+
function readJson(path) {
|
|
7
14
|
if (!existsSync(path))
|
|
8
|
-
return
|
|
9
|
-
let doc;
|
|
15
|
+
return { corrupt: false };
|
|
10
16
|
try {
|
|
11
|
-
doc
|
|
17
|
+
return { doc: JSON.parse(readFileSync(path, "utf8")), corrupt: false };
|
|
12
18
|
}
|
|
13
19
|
catch {
|
|
14
|
-
return
|
|
20
|
+
return { corrupt: true };
|
|
15
21
|
}
|
|
16
|
-
|
|
22
|
+
}
|
|
23
|
+
/** Turn a raw MCP server entry into a binding. Shared by all sources — the
|
|
24
|
+
* entry shape is identical wherever it is stored (see commands/mcp.ts). */
|
|
25
|
+
function parseServerEntry(s, source) {
|
|
17
26
|
if (!s)
|
|
18
27
|
return undefined;
|
|
19
28
|
// Canonical (RTSC-92): secret-free marker → resolve the key from the keystore.
|
|
@@ -21,23 +30,132 @@ export function readLocalBinding(dir) {
|
|
|
21
30
|
if (workspaceId) {
|
|
22
31
|
const entry = getBinding(workspaceId);
|
|
23
32
|
if (entry)
|
|
24
|
-
return { key: entry.key, url: entry.url, watchdog: true, workspaceId };
|
|
33
|
+
return { key: entry.key, url: entry.url, watchdog: true, workspaceId, source };
|
|
25
34
|
// Marker present but no keystore entry (e.g. a cloned repo) → needs binding.
|
|
26
|
-
return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true };
|
|
35
|
+
return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true, source };
|
|
27
36
|
}
|
|
28
37
|
// Legacy inline-key forms (pre-RTSC-92): key in env or the Authorization header.
|
|
29
38
|
if (s.env?.RETASC_MCP_KEY) {
|
|
30
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
key: String(s.env.RETASC_MCP_KEY),
|
|
41
|
+
url: String(s.env.RETASC_MCP_URL ?? ""),
|
|
42
|
+
watchdog: true,
|
|
43
|
+
legacy: true,
|
|
44
|
+
source,
|
|
45
|
+
};
|
|
31
46
|
}
|
|
32
47
|
const auth = s.headers?.Authorization ?? s.headers?.authorization;
|
|
33
48
|
if (auth) {
|
|
34
49
|
const m = String(auth).match(/^Bearer\s+(.+)$/i);
|
|
35
|
-
return { key: m ? m[1].trim() : String(auth).trim(), url: String(s.url ?? ""), watchdog: false, legacy: true };
|
|
50
|
+
return { key: m ? m[1].trim() : String(auth).trim(), url: String(s.url ?? ""), watchdog: false, legacy: true, source };
|
|
51
|
+
}
|
|
52
|
+
// An entry EXISTS but matches no shape we know. It still shadows lower scopes
|
|
53
|
+
// at runtime (Claude Code runs it regardless), so returning undefined here
|
|
54
|
+
// would let readLocalBinding fall back to a folder marker the agent is NOT
|
|
55
|
+
// using — the confident-wrong-org failure this module exists to prevent.
|
|
56
|
+
// Report presence without a key; doctor words it as "can't read a key".
|
|
57
|
+
return { key: "", url: "", watchdog: false, markerOnly: true, source };
|
|
58
|
+
}
|
|
59
|
+
/** The raw folder-scope (./.mcp.json) retasc entry, if any. */
|
|
60
|
+
function folderEntry(dir) {
|
|
61
|
+
return readJson(join(dir, ".mcp.json")).doc?.mcpServers?.retasc;
|
|
62
|
+
}
|
|
63
|
+
/** The raw Claude-Code-local-scope retasc entry for a folder, if any.
|
|
64
|
+
* Exported so the shared key resolver (claim.ts resolveMcpConn) sees the same
|
|
65
|
+
* binding this module does — keeping doctor/whoami and claim/tidy/done in
|
|
66
|
+
* lockstep (RTSC-98's "one place" rule extends to entry SELECTION too). */
|
|
67
|
+
export function claudeLocalRetascEntry(dir) {
|
|
68
|
+
const projects = readJson(claudeConfigPath()).doc?.projects;
|
|
69
|
+
if (!projects)
|
|
70
|
+
return undefined;
|
|
71
|
+
const direct = projects[dir]?.mcpServers?.retasc;
|
|
72
|
+
if (direct)
|
|
73
|
+
return direct;
|
|
74
|
+
// Claude Code's projects key derivation is undocumented (raw cwd vs realpath,
|
|
75
|
+
// trailing slash). Cheap insurance: retry with both sides path-normalized —
|
|
76
|
+
// worst case it misses and we fail safe to "not bound", never to a wrong match.
|
|
77
|
+
const norm = (p) => {
|
|
78
|
+
const stripped = p.replace(/\/+$/, "") || "/";
|
|
79
|
+
try {
|
|
80
|
+
return realpathSync(stripped);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return stripped;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const want = norm(dir);
|
|
87
|
+
for (const k of Object.keys(projects)) {
|
|
88
|
+
// Only keys that actually CARRY a retasc entry are candidates — this both
|
|
89
|
+
// avoids an early return on an empty match (a trailing-slash twin with no
|
|
90
|
+
// entry must not mask a symlink twin that has one) and shrinks the
|
|
91
|
+
// realpathSync surface to the handful of retasc-bearing keys. First
|
|
92
|
+
// carrying match wins (deterministic per file order).
|
|
93
|
+
const e = projects[k]?.mcpServers?.retasc;
|
|
94
|
+
if (!e || k === dir)
|
|
95
|
+
continue;
|
|
96
|
+
if (norm(k) === want)
|
|
97
|
+
return e;
|
|
36
98
|
}
|
|
37
99
|
return undefined;
|
|
38
100
|
}
|
|
101
|
+
/** Read the Retasc binding a folder's agent actually uses, from either legal
|
|
102
|
+
* location — claude-local first, matching Claude Code's runtime precedence. */
|
|
103
|
+
export function readLocalBinding(dir) {
|
|
104
|
+
const fromLocal = parseServerEntry(claudeLocalRetascEntry(dir), "claude-local");
|
|
105
|
+
if (fromLocal)
|
|
106
|
+
return fromLocal;
|
|
107
|
+
return parseServerEntry(folderEntry(dir), "folder");
|
|
108
|
+
}
|
|
109
|
+
/** The LOSING per-folder binding when a folder is bound in both places (the
|
|
110
|
+
* ./.mcp.json marker shadowed by a claude-local entry). Doctor warns when the
|
|
111
|
+
* two identities differ, so a stale committed marker can't mislead a team. */
|
|
112
|
+
export function readShadowedBinding(dir) {
|
|
113
|
+
if (!claudeLocalRetascEntry(dir))
|
|
114
|
+
return undefined;
|
|
115
|
+
return parseServerEntry(folderEntry(dir), "folder");
|
|
116
|
+
}
|
|
117
|
+
/** A user-scope (global) Retasc server: top-level in Claude Code's config,
|
|
118
|
+
* NOT under projects[dir]. Illegal under DESIGN §13 override #1 — it applies
|
|
119
|
+
* to every folder that has no binding of its own, so issues can land in the
|
|
120
|
+
* wrong project. `retasc bind` refuses to create one (normalizeScope); this
|
|
121
|
+
* catches one added by hand with `claude mcp add -s user`. */
|
|
122
|
+
export function readGlobalBinding() {
|
|
123
|
+
const { doc, corrupt } = readJson(claudeConfigPath());
|
|
124
|
+
if (corrupt)
|
|
125
|
+
return { status: "unreadable" };
|
|
126
|
+
const raw = doc?.mcpServers?.retasc;
|
|
127
|
+
if (!raw)
|
|
128
|
+
return { status: "none" };
|
|
129
|
+
return { status: "present", binding: parseServerEntry(raw, "global") };
|
|
130
|
+
}
|
|
131
|
+
/** Two per-folder bindings are the SAME binding if they agree on workspace id
|
|
132
|
+
* (canonical form) or on the literal key (legacy forms). Anything else — or
|
|
133
|
+
* not enough identity to compare — is a disagreement worth surfacing. */
|
|
134
|
+
export function sameIdentity(a, b) {
|
|
135
|
+
if (a.workspaceId && b.workspaceId)
|
|
136
|
+
return a.workspaceId === b.workspaceId;
|
|
137
|
+
if (a.key && b.key)
|
|
138
|
+
return a.key === b.key;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/** True when resolveBinding failed WITHOUT delivering a verdict on the key —
|
|
142
|
+
* the server was unreachable, timed out, threw 5xx, or rate-limited us. The
|
|
143
|
+
* advice differs: "check your network / try again" vs "re-run retasc bind".
|
|
144
|
+
* A 4xx (other than 429) IS a verdict: the server saw the key and said no. */
|
|
145
|
+
export function isNetworkError(e) {
|
|
146
|
+
const name = String(e?.name ?? "");
|
|
147
|
+
if (name === "TimeoutError" || name === "AbortError")
|
|
148
|
+
return true;
|
|
149
|
+
const msg = String(e?.message ?? e);
|
|
150
|
+
const status = msg.match(/^MCP server returned (\d{3})$/);
|
|
151
|
+
if (status)
|
|
152
|
+
return Number(status[1]) >= 500 || Number(status[1]) === 429;
|
|
153
|
+
return /fetch failed|network|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT/i.test(msg);
|
|
154
|
+
}
|
|
39
155
|
/** Resolve a key's org/project by calling the MCP `whoami` tool (server-enforced
|
|
40
|
-
* scope — the same banner the agent sees). Throws on an invalid/revoked key.
|
|
156
|
+
* scope — the same banner the agent sees). Throws on an invalid/revoked key.
|
|
157
|
+
* Bounded: doctor is the tool people run when things are broken, so it must
|
|
158
|
+
* never hang on a dead network (RTSC-262 review). */
|
|
41
159
|
export async function resolveBinding(url, key) {
|
|
42
160
|
const res = await fetch(url, {
|
|
43
161
|
method: "POST",
|
|
@@ -52,6 +170,7 @@ export async function resolveBinding(url, key) {
|
|
|
52
170
|
method: "tools/call",
|
|
53
171
|
params: { name: "whoami", arguments: {} },
|
|
54
172
|
}),
|
|
173
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
174
|
});
|
|
56
175
|
if (!res.ok)
|
|
57
176
|
throw new Error(`MCP server returned ${res.status}`);
|
package/dist/lib/claim.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// ../commands/claim.ts.
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
|
+
import { claudeLocalRetascEntry } from "./binding.js";
|
|
7
8
|
import { resolveConn } from "./keystore.js";
|
|
8
9
|
// Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
|
|
9
10
|
// re-exported so existing importers of this module keep working.
|
|
@@ -19,9 +20,15 @@ const DEFAULT_MCP_URL = "https://mcp.retasc.com/mcp";
|
|
|
19
20
|
* if nothing is found, so the caller can error clearly.
|
|
20
21
|
*/
|
|
21
22
|
export function resolveMcpConn(opts = {}) {
|
|
23
|
+
// RTSC-262: the binding may live in Claude Code's local scope instead of
|
|
24
|
+
// ./.mcp.json (the `claude mcp add` DEFAULT). Select the same entry the
|
|
25
|
+
// runtime does — claude-local wins over the folder marker (local > project) —
|
|
26
|
+
// so claim/tidy/done can never disagree with doctor/whoami about whether a
|
|
27
|
+
// folder is bound. Explicit env (the proxy path) still wins inside resolveConn.
|
|
28
|
+
const localEntry = claudeLocalRetascEntry(opts.dir ?? process.cwd());
|
|
22
29
|
const { key, url } = resolveConn({
|
|
23
30
|
env: opts.env,
|
|
24
|
-
mcpEntry: opts.mcpJson?.mcpServers?.retasc,
|
|
31
|
+
mcpEntry: localEntry ?? opts.mcpJson?.mcpServers?.retasc,
|
|
25
32
|
defaultUrl: opts.defaultUrl || DEFAULT_MCP_URL,
|
|
26
33
|
});
|
|
27
34
|
return { url: url || DEFAULT_MCP_URL, key };
|
package/dist/lib/keystore.js
CHANGED
|
@@ -60,26 +60,43 @@ export function newWorkspaceId() {
|
|
|
60
60
|
* secret-free marker (`RETASC_WORKSPACE` → home keystore). `mcpEntry` is a
|
|
61
61
|
* workspace `.mcp.json`'s `mcpServers.retasc` entry, or undefined when only the
|
|
62
62
|
* environment carries the binding (the spawned proxy).
|
|
63
|
+
*
|
|
64
|
+
* URL trust pairs with key source (RTSC-262 review): a keystore-resolved key
|
|
65
|
+
* travels ONLY to the keystore's url (or explicit env url) — never to a url
|
|
66
|
+
* from the entry. The marker is secret-free and committed by design, so a
|
|
67
|
+
* hostile edit adding RETASC_MCP_URL next to RETASC_WORKSPACE must not be able
|
|
68
|
+
* to redirect a teammate's real key. Legacy inline entries keep their own url:
|
|
69
|
+
* there the key and url are co-located, same trust domain.
|
|
63
70
|
*/
|
|
64
71
|
export function resolveConn(opts) {
|
|
65
72
|
const env = opts.env ?? process.env;
|
|
66
73
|
const entry = opts.mcpEntry;
|
|
67
74
|
let key = env.RETASC_MCP_KEY || "";
|
|
68
75
|
let url = env.RETASC_MCP_URL || "";
|
|
76
|
+
// Entry-supplied key/url are read together — the url is honored only when
|
|
77
|
+
// the key it's paired with is the one actually used.
|
|
78
|
+
let entryKey = "";
|
|
79
|
+
let entryUrl = "";
|
|
69
80
|
if (entry) {
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
if (!
|
|
81
|
+
if (typeof entry.env?.RETASC_MCP_KEY === "string")
|
|
82
|
+
entryKey = entry.env.RETASC_MCP_KEY;
|
|
83
|
+
if (typeof entry.env?.RETASC_MCP_URL === "string")
|
|
84
|
+
entryUrl = entry.env.RETASC_MCP_URL;
|
|
85
|
+
if (!entryUrl && typeof entry.url === "string")
|
|
86
|
+
entryUrl = entry.url;
|
|
87
|
+
if (!entryKey && typeof entry.headers?.Authorization === "string") {
|
|
77
88
|
const m = String(entry.headers.Authorization).match(/^Bearer\s+(.+)$/i);
|
|
78
89
|
if (m)
|
|
79
|
-
|
|
90
|
+
entryKey = m[1].trim();
|
|
80
91
|
}
|
|
81
92
|
}
|
|
93
|
+
if (!key && entryKey) {
|
|
94
|
+
key = entryKey;
|
|
95
|
+
if (!url)
|
|
96
|
+
url = entryUrl;
|
|
97
|
+
}
|
|
82
98
|
// Canonical (RTSC-92): secret-free marker → workspace id → home keystore.
|
|
99
|
+
// The entry's url is deliberately NOT consulted on this branch.
|
|
83
100
|
if (!key) {
|
|
84
101
|
const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
|
|
85
102
|
if (wsId) {
|
package/package.json
CHANGED