@retasc/cli 1.38.2 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,49 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.39.1 (2026-09-02)
10
+
11
+ - **RTSC-800** — (security) a keystore-resolved key now travels only to the keystore's own
12
+ url, whether the competing url arrives in the `.mcp.json` entry or in the environment.
13
+ 1.6.0 made this promise for the entry, which covered `claim`/`tidy`/`done` but not the
14
+ proxy: the proxy is the one caller that ever reads a committed marker, and it passes no
15
+ entry at all. The harness copies the marker's `env` block into the spawned process, so a
16
+ `RETASC_MCP_URL` planted next to `RETASC_WORKSPACE` in a repo's committed `.mcp.json`
17
+ arrived as an environment url, outranked the keystore, and every teammate's proxy sent its
18
+ real workspace key to that host on the next start, during session-key minting, with no
19
+ prompt. An environment url is now honored only alongside an explicit `RETASC_MCP_KEY`,
20
+ where the key and the url share a trust domain.
21
+ Nothing legitimate loses a path: `bind` already stores the deployment's url into the
22
+ binding, so a self-hosted url is in the keystore before the proxy asks for it.
23
+ This closes the url, and only the url. A committed marker also chooses the `command` that
24
+ runs and can set any other variable, so a marker you do not trust is code execution by the
25
+ MCP config format's own design, and the harness's project-trust prompt is the boundary for
26
+ that. What this removes is the one variant that reads as ordinary configuration in review.
27
+
28
+ ## 1.39.0 (2026-08-30)
29
+
30
+ - **RTSC-793** — `retasc setup` now wires Cursor, OpenCode and Gemini CLI too, bringing it
31
+ to six harnesses. Until this release those three could be installed and running on your
32
+ machine and `setup` would say nothing about them: it only ever named harnesses it already
33
+ knew, so an unsupported one was not reported as skipped, it was invisible, and the receipt
34
+ read as complete. That is the same silent half-install the previous release existed to end,
35
+ one harness over.
36
+ Each was admitted the same way the first three were, by checking a real installation rather
37
+ than a config format from memory. The check that decides it is whether the harness starts
38
+ its MCP server in the project directory, because the entry we write names no project and
39
+ works out the folder for itself; one that started somewhere else would resolve every folder
40
+ to the same wrong place with no symptom. All three passed.
41
+ Cursor is wired by editing `~/.cursor/mcp.json` directly, and carefully: that file is named
42
+ .json but Cursor accepts comments in it, so we splice our entry in textually and leave every
43
+ comment, every other server and everything else exactly where it was. OpenCode and Gemini
44
+ are wired through their own `opencode mcp add` and `gemini mcp add`, which is better than a
45
+ writer of ours when a harness ships one that works: their file format stays their business.
46
+ Note for Cursor users: it is `cursor-agent`, the agent, that gets the tools. The `cursor`
47
+ command is the editor launcher and is a different program.
48
+ Also fixed: the setup receipt could run a path into the word beside it, printing
49
+ `~/.codex/config.tomlupdated`, which read like a corrupted path in the one place whose job
50
+ is to say plainly that everything worked.
51
+
9
52
  ## 1.38.2 (2026-08-30)
10
53
 
11
54
  - **RTSC-789** — `bind` keeps this folder's key instead of minting a new one on every run.
@@ -66,8 +66,12 @@ export function runSetup(opts) {
66
66
  export function printSetup(r) {
67
67
  if (!r.wired.length && !r.failed.length) {
68
68
  console.log("No MCP harness found on this machine.");
69
- console.log("Install Claude Code, Codex or Grok and run `retasc setup` again, or bind a folder\n" +
70
- "with `retasc bind` and paste the config block it prints into your own client.");
69
+ // Named from the registry rather than a hand-kept list: RTSC-780 shipped three and
70
+ // RTSC-793 added three more, and a sentence naming only the original three would have
71
+ // told a Cursor user to install Codex.
72
+ console.log(`Install one of ${HARNESSES.map((h) => h.label).join(", ")} and run \`retasc setup\` again,\n` +
73
+ "or bind a folder with `retasc bind` and paste the config block it prints into your\n" +
74
+ "own client.");
71
75
  return;
72
76
  }
73
77
  console.log("\n" +
package/dist/lib/card.js CHANGED
@@ -50,8 +50,14 @@ export function card(title, rows, footer) {
50
50
  // A note sits in a third column, so the values stay in one scannable rail. Without a
51
51
  // note the value simply runs on — padding it anyway would leave a trench of spaces
52
52
  // in the middle of the card.
53
+ //
54
+ // The value is fitted one column NARROWER than its rail so a gutter always survives.
55
+ // `fit` pads to exactly VALUE, so a value of exactly that width, or one ellipsed to
56
+ // it, butted straight against the note: `~/.codex/config.tomlupdated`. It read as a
57
+ // corrupted path in the one surface whose job is to say calmly that this worked
58
+ // (RTSC-793 — RTSC-780's three rows all happened to be shorter).
53
59
  const body = r.note
54
- ? `${fit(r.label, LABEL)}${fit(r.value, VALUE)}${r.note}`
60
+ ? `${fit(r.label, LABEL)}${fit(r.value, VALUE - 1)} ${r.note}`
55
61
  : `${fit(r.label, LABEL)}${r.value}`;
56
62
  out.push(line(body));
57
63
  }
@@ -13,10 +13,16 @@
13
13
  // with cwd set to the project directory, so the proxy can resolve the folder itself.
14
14
  //
15
15
  // Adding a harness is one entry in HARNESSES. Deliberately NOT populated from theory:
16
- // each entry here was checked against a real installation. Cursor, Windsurf, Gemini CLI,
17
- // OpenCode and VS Code all have a known config shape and belong here, but writing an
18
- // unverified serializer into somebody's global agent config is not a thing to ship on a
19
- // guess they go in as each is confirmed.
16
+ // every entry here was checked against a real installation, and the check that gates
17
+ // admission is the cwd one a harness whose stdio server does not start in the project
18
+ // directory cannot use the `auto` marker at all, because one global entry would then
19
+ // resolve every folder to the same wrong place, silently.
20
+ //
21
+ // Verified so far: Claude Code, Codex, Grok (RTSC-780), then Cursor, OpenCode and Gemini
22
+ // CLI (RTSC-793), each confirmed by wiring a probe server that logs process.cwd() and
23
+ // running the harness from two different directories. Windsurf and VS Code are NOT
24
+ // candidates: they are editors, not agent harnesses, and were dropped from RTSC-780's
25
+ // original map rather than left sitting in it forever.
20
26
  import { spawnSync } from "node:child_process";
21
27
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
28
  import { homedir } from "node:os";
@@ -147,22 +153,186 @@ function tomlHarness(id, label, path, bin) {
147
153
  },
148
154
  };
149
155
  }
150
- // --- JSON (./.mcp.json in the folder) ---------------------------------------
151
- /** Merge the retasc entry into a `{ mcpServers: {…} }` document, leaving every
152
- * other server and unknown key byte-for-byte where it was. */
153
- export function mergeMcpServers(before, entry) {
154
- let doc = {};
155
- if (before.trim()) {
156
- try {
157
- doc = JSON.parse(before);
156
+ // --- JSONC (Cursor) ---------------------------------------------------------
157
+ // `~/.cursor/mcp.json` is named .json and is NOT parsed as JSON: cursor-agent reads a
158
+ // file containing `// comments` without complaint (verified against a real install,
159
+ // RTSC-793). So the same rule the TOML splice follows applies here — this is the user's
160
+ // own config, and a JSON.parse/JSON.stringify round-trip would silently delete every
161
+ // comment in it and reflow the rest.
162
+ //
163
+ // Hence a textual splice, and hence the small JSONC scanner below rather than a parser
164
+ // dependency. It only ever has to find one member and replace its value; everything it
165
+ // does not understand it steps over untouched, which is exactly the property we want
166
+ // from something editing a file we did not write.
167
+ /** Step over whitespace and JSONC comments, returning the next index that is neither. */
168
+ function skipTrivia(text, i) {
169
+ for (;;) {
170
+ while (i < text.length && /\s/.test(text[i]))
171
+ i++;
172
+ if (text.startsWith("//", i)) {
173
+ const nl = text.indexOf("\n", i);
174
+ i = nl === -1 ? text.length : nl + 1;
175
+ continue;
158
176
  }
159
- catch {
160
- doc = {};
177
+ if (text.startsWith("/*", i)) {
178
+ const end = text.indexOf("*/", i + 2);
179
+ i = end === -1 ? text.length : end + 2;
180
+ continue;
181
+ }
182
+ return i;
183
+ }
184
+ }
185
+ /** `i` points at the opening quote. Returns the index just past the closing quote. */
186
+ function endOfString(text, i) {
187
+ i++;
188
+ while (i < text.length) {
189
+ if (text[i] === "\\")
190
+ i += 2;
191
+ else if (text[i] === '"')
192
+ return i + 1;
193
+ else
194
+ i++;
195
+ }
196
+ return i;
197
+ }
198
+ /** `i` points at the first character of a value. Returns the index just past it.
199
+ * Nesting is tracked so an object value containing commas ends in the right place. */
200
+ function endOfValue(text, i) {
201
+ i = skipTrivia(text, i);
202
+ if (text[i] === '"')
203
+ return endOfString(text, i);
204
+ if (text[i] === "{" || text[i] === "[") {
205
+ let depth = 0;
206
+ while (i < text.length) {
207
+ const c = text[i];
208
+ if (c === '"') {
209
+ i = endOfString(text, i);
210
+ continue;
211
+ }
212
+ if (c === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
213
+ i = skipTrivia(text, i);
214
+ continue;
215
+ }
216
+ if (c === "{" || c === "[")
217
+ depth++;
218
+ else if (c === "}" || c === "]") {
219
+ depth--;
220
+ if (depth === 0)
221
+ return i + 1;
222
+ }
223
+ i++;
161
224
  }
225
+ return i;
162
226
  }
163
- doc.mcpServers = doc.mcpServers ?? {};
164
- doc.mcpServers[SERVER_NAME] = entry;
165
- return JSON.stringify(doc, null, 2) + "\n";
227
+ while (i < text.length && !/[,}\]\s]/.test(text[i]))
228
+ i++;
229
+ return i;
230
+ }
231
+ /**
232
+ * The members of the object whose `{` is at `open`, with the span of each value.
233
+ *
234
+ * `advance` is load-bearing, not defensive tidiness. `endOfValue` stops AT its
235
+ * terminator set, so on a character already in that set (a stray `]`) it returns the
236
+ * index it was handed, and a loop that trusted it would spin forever. That input is not
237
+ * exotic: this parses a file a human hand-edits, and a dropped bracket is the ordinary
238
+ * way one gets malformed. `retasc setup` hanging with no output, taking `bind` and the
239
+ * whole of onboarding with it, is a far worse failure than any misparse — so every path
240
+ * through this loop moves at least one character, and a document we cannot understand
241
+ * ends as a document we scanned to the end of.
242
+ */
243
+ function membersOf(text, open) {
244
+ const members = [];
245
+ let i = open + 1;
246
+ const advance = (next) => (next > i ? next : i + 1);
247
+ for (;;) {
248
+ i = skipTrivia(text, i);
249
+ if (i >= text.length || text[i] === "}")
250
+ return { members };
251
+ if (text[i] === ",") {
252
+ i++;
253
+ continue;
254
+ }
255
+ if (text[i] !== '"') {
256
+ i = advance(endOfValue(text, i));
257
+ continue;
258
+ }
259
+ const start = i;
260
+ const keyEnd = endOfString(text, i);
261
+ const name = text.slice(start + 1, keyEnd - 1);
262
+ const j = skipTrivia(text, keyEnd);
263
+ if (text[j] !== ":") {
264
+ i = advance(keyEnd);
265
+ continue;
266
+ }
267
+ const valueStart = skipTrivia(text, j + 1);
268
+ const valueEnd = endOfValue(text, valueStart);
269
+ members.push({ name, start, valueStart, valueEnd });
270
+ i = advance(valueEnd);
271
+ }
272
+ }
273
+ /** The first top-level `{`, or -1 if this document has no root object. */
274
+ function rootObject(text) {
275
+ const i = skipTrivia(text, 0);
276
+ return text[i] === "{" ? i : -1;
277
+ }
278
+ /**
279
+ * Put `value` at `mcpServers.<name>`, leaving every comment, every other server and
280
+ * every unknown key exactly where they were.
281
+ *
282
+ * Three cases, in the order they are cheapest to be sure about: no usable document at
283
+ * all (write a fresh one), a document with no `mcpServers` (insert the whole object),
284
+ * and a document that already has one (replace our member's value, or insert it).
285
+ */
286
+ export function spliceJsonServer(text, name, value) {
287
+ const indent = (s, by) => s.split("\n").join(`\n${by}`);
288
+ const root = rootObject(text);
289
+ if (root === -1) {
290
+ return `{\n "mcpServers": {\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }\n}\n`;
291
+ }
292
+ const { members } = membersOf(text, root);
293
+ const servers = members.find((m) => m.name === "mcpServers");
294
+ if (!servers) {
295
+ const block = `"mcpServers": {\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }`;
296
+ // A trailing comma is only correct if something follows; an empty root gets neither.
297
+ const sep = members.length ? ",\n " : "\n ";
298
+ const head = members.length ? text.slice(0, members[0].start) : text.slice(0, root + 1);
299
+ const tail = members.length ? text.slice(members[0].start) : text.slice(root + 1);
300
+ return members.length
301
+ ? `${head}${block}${sep}${tail}`
302
+ : `${head}${sep}${block}\n${tail}`;
303
+ }
304
+ // `mcpServers` present but not an OBJECT. Scanning on would treat the rest of the
305
+ // document as its members and splice our entry one character into a number or a
306
+ // string, writing a corrupt file over a config we were handed intact — the one
307
+ // outcome this whole textual approach exists to prevent. We own this key, and a
308
+ // non-object here is already meaningless to every harness that reads it, so replace
309
+ // the value wholesale and leave the rest of the document alone.
310
+ if (text[servers.valueStart] !== "{") {
311
+ const fresh = `{\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }`;
312
+ return text.slice(0, servers.valueStart) + fresh + text.slice(servers.valueEnd);
313
+ }
314
+ const inner = membersOf(text, servers.valueStart);
315
+ const mine = inner.members.find((m) => m.name === name);
316
+ if (mine) {
317
+ return text.slice(0, mine.valueStart) + indent(value, " ") + text.slice(mine.valueEnd);
318
+ }
319
+ const at = servers.valueStart + 1;
320
+ const insert = `\n ${JSON.stringify(name)}: ${indent(value, " ")}${inner.members.length ? "," : ""}`;
321
+ return text.slice(0, at) + insert + text.slice(at);
322
+ }
323
+ /** True when `mcpServers.<name>` is already present — the receipt's "updated" vs "new". */
324
+ export function hasJsonServer(text, name) {
325
+ const root = rootObject(text);
326
+ if (root === -1)
327
+ return false;
328
+ const servers = membersOf(text, root).members.find((m) => m.name === "mcpServers");
329
+ if (!servers || text[servers.valueStart] !== "{")
330
+ return false;
331
+ return membersOf(text, servers.valueStart).members.some((m) => m.name === name);
332
+ }
333
+ /** Our entry, in the shape Cursor reads: command + args + env. */
334
+ export function jsonServerValue(entry) {
335
+ return JSON.stringify({ command: entry.command, args: entry.args, env: entry.env }, null, 2);
166
336
  }
167
337
  // --- Claude Code ------------------------------------------------------------
168
338
  /**
@@ -217,11 +387,139 @@ const claudeCode = {
217
387
  return { ok: true, path: "Claude Code (user scope)", replaced: had.status === 0 };
218
388
  },
219
389
  };
390
+ // --- Cursor -----------------------------------------------------------------
391
+ /**
392
+ * `cursor-agent`, NOT `cursor`.
393
+ *
394
+ * They are two different binaries and only one of them is a harness: `cursor` is the
395
+ * VS Code-style IDE launcher (`--diff`, `--merge`, `--goto`), while `cursor-agent` is
396
+ * the agent, and `cursor-agent mcp login` names `.cursor/mcp.json` or `~/.cursor/mcp.json`
397
+ * as where it reads servers from. Detecting on `cursor` would report a harness we had
398
+ * not wired anything usable into.
399
+ *
400
+ * The one adapter here that writes its own file: `cursor-agent mcp` offers login, list,
401
+ * enable and disable, but no `add`.
402
+ */
403
+ const cursor = {
404
+ id: "cursor",
405
+ label: "Cursor",
406
+ configPath: () => join(home(), ".cursor", "mcp.json"),
407
+ detect: () => existsSync(join(home(), ".cursor")) || onPath("cursor-agent"),
408
+ install(entry) {
409
+ const p = join(home(), ".cursor", "mcp.json");
410
+ const before = readIfExists(p);
411
+ try {
412
+ writeConfig(p, spliceJsonServer(before, SERVER_NAME, jsonServerValue(entry)));
413
+ }
414
+ catch (e) {
415
+ return { ok: false, reason: String(e?.message ?? e) };
416
+ }
417
+ return { ok: true, path: p, replaced: hasJsonServer(before, SERVER_NAME) };
418
+ },
419
+ };
420
+ // --- harnesses with their own `mcp add` -------------------------------------
421
+ /**
422
+ * OpenCode and Gemini both ship the thing Claude Code ships and Codex, Grok and Cursor
423
+ * do not: a supported command for this. Each was checked against a real install
424
+ * (RTSC-793) for the three properties that decide whether calling it beats writing the
425
+ * file ourselves, and both have all three: it preserves comments and key order, it
426
+ * leaves other servers and unknown keys alone, and re-adding the same name REPLACES
427
+ * rather than failing (so `runSetup` on every bind stays idempotent).
428
+ *
429
+ * Given that, shelling out is strictly better than a serializer of ours: their config
430
+ * format is theirs to change, and a format change breaks our writer silently while
431
+ * their own command keeps working.
432
+ *
433
+ * `replaced` is read from the file rather than from what the command prints, so the
434
+ * receipt does not depend on anybody's wording staying put.
435
+ */
436
+ function addCommandHarness(opts) {
437
+ return {
438
+ id: opts.id,
439
+ label: opts.label,
440
+ configPath: opts.configPath,
441
+ // Gated on RETASC_HOME for the same reason Claude Code is: this shells out to a
442
+ // command that resolves the REAL home, so a test home has to stop it at DETECTION.
443
+ // An undetected harness is never installed into.
444
+ detect: () => !process.env.RETASC_HOME && onPath(opts.bin),
445
+ install(entry) {
446
+ const before = readIfExists(opts.configPath());
447
+ const r = spawnSync(opts.bin, opts.args(entry), { encoding: "utf8" });
448
+ if (r.error)
449
+ return { ok: false, reason: r.error.message };
450
+ if (r.status !== 0) {
451
+ const msg = (r.stderr || r.stdout || "").trim().split("\n").pop() || `exited ${r.status}`;
452
+ return { ok: false, reason: msg };
453
+ }
454
+ return {
455
+ ok: true,
456
+ path: opts.configPath(),
457
+ replaced: before.includes(`"${SERVER_NAME}"`),
458
+ };
459
+ },
460
+ };
461
+ }
462
+ /** `~/.config/opencode/`, XDG-aware — OpenCode honours XDG_CONFIG_HOME, so resolving it
463
+ * to `~/.config` unconditionally would name the wrong file in the receipt. */
464
+ function opencodeConfig() {
465
+ const xdg = process.env.XDG_CONFIG_HOME || join(home(), ".config");
466
+ return join(xdg, "opencode", "opencode.jsonc");
467
+ }
468
+ /**
469
+ * `opencode mcp add <name> --env K=V -- <command> <args...>`.
470
+ *
471
+ * The `--` is required: without a URL or a command after it, `add` refuses. Note the
472
+ * stored shape is unlike every other harness here — the key is `mcp` rather than
473
+ * `mcpServers`, it carries `"type": "local"`, `command` is ONE array of command plus
474
+ * args, and env is spelled `environment`. A generic mcpServers writer would produce a
475
+ * file OpenCode ignores, which is the other half of why this one shells out.
476
+ */
477
+ const opencode = addCommandHarness({
478
+ id: "opencode",
479
+ label: "OpenCode",
480
+ bin: "opencode",
481
+ configPath: opencodeConfig,
482
+ args: (entry) => [
483
+ "mcp", "add", SERVER_NAME,
484
+ ...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
485
+ "--", entry.command, ...entry.args,
486
+ ],
487
+ });
488
+ /**
489
+ * `gemini mcp add -s user -t stdio -e K=V <name> <command> <args...>`.
490
+ *
491
+ * Scope `user`, for the reason the whole marker exists: one entry, correct in every
492
+ * folder. The default is `project`, which would write a `.gemini/` into whichever
493
+ * directory `retasc setup` happened to run in.
494
+ *
495
+ * Two Gemini quirks, neither ours to fix but both worth knowing before someone reports
496
+ * them as our bug. `gemini mcp list` reports a freshly added server as `Disabled` and
497
+ * `gemini mcp enable <name>` answers `Server not found` for it — yet the server does
498
+ * start and connect on a real run, so `list` is not a verification signal. And Gemini
499
+ * gates on trusted folders: in an untrusted directory it refuses to start and NO MCP
500
+ * server loads, which looks exactly like a failed install.
501
+ */
502
+ const gemini = addCommandHarness({
503
+ id: "gemini",
504
+ label: "Gemini CLI",
505
+ bin: "gemini",
506
+ configPath: () => join(home(), ".gemini", "settings.json"),
507
+ args: (entry) => [
508
+ "mcp", "add",
509
+ "-s", "user",
510
+ "-t", "stdio",
511
+ ...Object.entries(entry.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
512
+ SERVER_NAME, entry.command, ...entry.args,
513
+ ],
514
+ });
220
515
  // --- the registry -----------------------------------------------------------
221
516
  export const HARNESSES = [
222
517
  claudeCode,
223
518
  tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"),
224
519
  tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok"),
520
+ cursor,
521
+ opencode,
522
+ gemini,
225
523
  ];
226
524
  /** Every harness actually present on this machine. */
227
525
  export function detectHarnesses(list = HARNESSES) {
@@ -136,18 +136,41 @@ export function findBindingByPath(dir) {
136
136
  * workspace `.mcp.json`'s `mcpServers.retasc` entry, or undefined when only the
137
137
  * environment carries the binding (the spawned proxy).
138
138
  *
139
- * URL trust pairs with key source (RTSC-262 review): a keystore-resolved key
140
- * travels ONLY to the keystore's url (or explicit env url) never to a url
141
- * from the entry. The marker is secret-free and committed by design, so a
142
- * hostile edit adding RETASC_MCP_URL next to RETASC_WORKSPACE must not be able
143
- * to redirect a teammate's real key. Legacy inline entries keep their own url:
144
- * there the key and url are co-located, same trust domain.
139
+ * URL trust pairs with key source (RTSC-262 review, tightened by RTSC-800): a url
140
+ * is honored only when it arrived WITH the key actually used. A keystore-resolved
141
+ * key therefore travels only to the keystore's url never to one from the entry,
142
+ * and never to one from the environment. Co-located pairs keep their own url,
143
+ * because there the key and url share a trust domain: a legacy inline entry, and
144
+ * an explicit `RETASC_MCP_KEY` + `RETASC_MCP_URL` in the environment.
145
+ *
146
+ * RTSC-800 is why the env url is PAIRED rather than absolute. RTSC-262 made this
147
+ * promise but implemented it on the `mcpEntry` branch only, and the proxy — the
148
+ * one caller that ever reads a committed marker — passes no entry. It hands us
149
+ * `process.env`, which the harness populates from the marker's own `env` block
150
+ * (`mcpMarkerEntry` / `autoMarkerEntry`). So a `RETASC_MCP_URL` planted next to
151
+ * `RETASC_WORKSPACE` in a committed `.mcp.json` arrived as an env url, won, and
152
+ * every teammate's proxy posted its real keystore key to that host on the next
153
+ * harness start. Nothing legitimate loses a path: `bind` stores the deployment's
154
+ * url INTO the binding, so a self-hosted url is already in the keystore by the
155
+ * time the proxy asks for it.
156
+ *
157
+ * This closes the url, and only the url. A hostile committed marker can set any
158
+ * variable for the spawned proxy (`RETASC_DIR` relocates the keystore outright —
159
+ * see `keystoreDir` above — and `NODE_OPTIONS` is arbitrary code) and it chooses
160
+ * the `command` that runs, so a marker you do not trust is
161
+ * code execution by the MCP config format's own design — the harness's project-
162
+ * trust prompt is the boundary for that, not this function. What this removes is
163
+ * the one variant that survives review, because a url string in a config file
164
+ * looks like configuration.
145
165
  */
146
166
  export function resolveConn(opts) {
147
167
  const env = opts.env ?? process.env;
148
168
  const entry = opts.mcpEntry;
149
169
  let key = env.RETASC_MCP_KEY || "";
150
- let url = env.RETASC_MCP_URL || "";
170
+ // Paired with the env KEY, not read unconditionally (RTSC-800): without a key
171
+ // beside it, an env url is just a string the committed marker chose, and the key
172
+ // it would redirect belongs to the keystore.
173
+ let url = key ? env.RETASC_MCP_URL || "" : "";
151
174
  // Entry-supplied key/url are read together — the url is honored only when
152
175
  // the key it's paired with is the one actually used.
153
176
  let entryKey = "";
@@ -171,7 +194,8 @@ export function resolveConn(opts) {
171
194
  url = entryUrl;
172
195
  }
173
196
  // Canonical (RTSC-92): secret-free marker → workspace id → home keystore.
174
- // The entry's url is deliberately NOT consulted on this branch.
197
+ // Neither the entry's url nor the environment's is consulted on this branch
198
+ // the key came from the keystore, so the url does too (RTSC-800).
175
199
  //
176
200
  // RTSC-780 adds one more step in front of the id: `auto` names no workspace, so
177
201
  // the folder we were spawned in picks it. Same trust rule either way — the key
@@ -187,8 +211,12 @@ export function resolveConn(opts) {
187
211
  : undefined;
188
212
  if (b) {
189
213
  key = b.key;
190
- if (!url)
191
- url = b.url;
214
+ // Unconditional, where this used to be `if (!url)`. Equivalent today — reaching
215
+ // here means `key` was empty, and both writes to `url` above are key-gated, so it
216
+ // is empty too — and that is the point: the rule "a keystore key rides the keystore
217
+ // url" is stated here outright rather than left resting on that invariant, so
218
+ // restoring an unpaired url above cannot quietly resurrect the RTSC-800 redirect.
219
+ url = b.url;
192
220
  }
193
221
  }
194
222
  return { key, url: url || opts.defaultUrl || "" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.38.2",
3
+ "version": "1.39.1",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {