@retasc/cli 1.36.1 → 1.38.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/CHANGELOG.md +47 -0
- package/dist/api.js +12 -0
- package/dist/commands/bind.js +21 -0
- package/dist/commands/mcp.js +28 -0
- package/dist/commands/setup.js +86 -0
- package/dist/commands/triage.js +171 -0
- package/dist/index.js +41 -5
- package/dist/lib/harness.js +236 -0
- package/dist/lib/keystore.js +92 -9
- package/dist/proxy.js +31 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,53 @@ 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.38.0 (2026-08-28)
|
|
10
|
+
|
|
11
|
+
- **RTSC-780** — `retasc setup` wires Retasc into every MCP harness on the machine, once,
|
|
12
|
+
and `retasc bind` now runs it for you. Until this release the CLI could wire exactly one
|
|
13
|
+
harness: it spawned `claude mcp add`, and if that worked it stopped. Codex, Grok and
|
|
14
|
+
anything else got nothing, which is why an agent bound to the same project as two
|
|
15
|
+
working Claude Code sessions could sit there unable to claim a single issue.
|
|
16
|
+
The entry we write names no project and carries no key. The watchdog proxy works out
|
|
17
|
+
which project it is in from the directory it was spawned in, so one line in a global
|
|
18
|
+
config is correct in every folder, and the folder still decides the org exactly as
|
|
19
|
+
before. Adding a harness is one entry in the registry; Claude Code, Codex and Grok ship
|
|
20
|
+
today, each verified against a real installation rather than a config format from
|
|
21
|
+
memory.
|
|
22
|
+
A folder you have not bound yet is now an ordinary state rather than a broken install,
|
|
23
|
+
so it gets a real answer: the tool call comes back naming the folder and telling you to
|
|
24
|
+
run `retasc bind` in it. Previously a keyless call surfaced as an authentication or
|
|
25
|
+
network failure while the CLI's own login was global and still fine, which reads as
|
|
26
|
+
"the Retasc server is down" and sends you to check a server that is serving everyone
|
|
27
|
+
else. The server cannot name your folder; the proxy runs in it, so it can.
|
|
28
|
+
Which folder counts is bounded by the repository: a subdirectory resolves to its repo,
|
|
29
|
+
and a repo checked out inside a bound directory resolves to nothing. Binding a
|
|
30
|
+
directory must not quietly bind every unrelated project underneath it.
|
|
31
|
+
Existing markers are untouched: one naming a workspace id keeps resolving through that
|
|
32
|
+
id, and keeps priority over the folder.
|
|
33
|
+
|
|
34
|
+
- **RTSC-781** — `--runtime` now says what it does. It sets the agent's label in the
|
|
35
|
+
Dash, and it never chose where config was written, but it is offered on the commands
|
|
36
|
+
that install MCP config and documented with a list of harness names, so
|
|
37
|
+
`key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing.
|
|
38
|
+
The help text says label, and the commands that mint a key and write a key-bearing
|
|
39
|
+
entry now say plainly that they write for this folder only, that a key never belongs
|
|
40
|
+
in a machine-wide config, and that `retasc setup` is what wires the harness you named.
|
|
41
|
+
|
|
42
|
+
## 1.37.0 (2026-08-26)
|
|
43
|
+
|
|
44
|
+
- **RTSC-749** — `retasc triage` reads and approves work filed from outside your org. Work
|
|
45
|
+
that arrives through a GitHub or GitLab connector is written by whoever can file on that
|
|
46
|
+
repo, and since RTSC-746 no agent can pick it up until a person has read it and approved
|
|
47
|
+
it. This is that decision from the terminal: `retasc triage` lists what is waiting,
|
|
48
|
+
`retasc triage RTSC-42` prints the full body and then asks.
|
|
49
|
+
It is deliberately hard to automate, because the CLI runs where coding agents run: there
|
|
50
|
+
is no `--approve` flag, the command refuses to run without an interactive terminal, and
|
|
51
|
+
confirming means retyping the issue id rather than pressing y. Saying no is as cheap as
|
|
52
|
+
saying yes (type `reject`) — the safe answer must never be the expensive one. The Dash
|
|
53
|
+
stays the recommended surface: an agent that can drive a real PTY on your machine could
|
|
54
|
+
drive this command too, and only logging the CLI out takes that away.
|
|
55
|
+
|
|
9
56
|
## 1.36.1 (2026-08-25)
|
|
10
57
|
|
|
11
58
|
- **RTSC-645** — `retasc gate install` no longer throws away your edits. It rewrites the
|
package/dist/api.js
CHANGED
|
@@ -44,6 +44,14 @@ const fns = {
|
|
|
44
44
|
// imported from X before?" once the progress row is gone.
|
|
45
45
|
latestImport: makeFunctionReference("import:latestImport"),
|
|
46
46
|
importHistory: makeFunctionReference("import:importHistory"),
|
|
47
|
+
// RTSC-749 — the triage door. These are the ONLY CLI calls that must never be
|
|
48
|
+
// reachable with an agent key: they are `convex/triage.ts`, which authenticates through
|
|
49
|
+
// Convex Auth and rejects API keys outright (RTSC-746). The CLI reaches them over the
|
|
50
|
+
// human's device-flow SESSION, like billing and invites — never over the workspace MCP
|
|
51
|
+
// key the issue commands use.
|
|
52
|
+
listQuarantined: makeFunctionReference("triage:listQuarantined"),
|
|
53
|
+
approveExternal: makeFunctionReference("triage:approveExternal"),
|
|
54
|
+
rejectExternal: makeFunctionReference("triage:rejectExternal"),
|
|
47
55
|
claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
|
|
48
56
|
claimGhost: makeFunctionReference("ghosts:claimGhost"),
|
|
49
57
|
dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
|
|
@@ -217,6 +225,10 @@ export const api = {
|
|
|
217
225
|
listImportTargets: (args) => withAuth(() => client().action(fns.listImportTargets, args)),
|
|
218
226
|
listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
|
|
219
227
|
listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
|
|
228
|
+
// RTSC-749 — see `fns` above for why these ride the user session, not the MCP key.
|
|
229
|
+
listQuarantined: (args) => withAuth(() => client().query(fns.listQuarantined, args)),
|
|
230
|
+
approveExternal: (args) => withAuth(() => client().mutation(fns.approveExternal, args)),
|
|
231
|
+
rejectExternal: (args) => withAuth(() => client().mutation(fns.rejectExternal, args)),
|
|
220
232
|
runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
|
|
221
233
|
latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
|
|
222
234
|
importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
|
package/dist/commands/bind.js
CHANGED
|
@@ -3,6 +3,7 @@ import { api, cliError } from "../api.js";
|
|
|
3
3
|
import { deviceLogin } from "../auth.js";
|
|
4
4
|
import { loadConfig, patchConfig } from "../config.js";
|
|
5
5
|
import { installMarker, printMarkerBlock } from "./mcp.js";
|
|
6
|
+
import { runSetup } from "./setup.js";
|
|
6
7
|
import { readLocalBinding, resolveBinding, bindingRefused } from "../lib/binding.js";
|
|
7
8
|
import { getBinding, setBinding, removeBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
8
9
|
import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp } from "../lib/launcher.js";
|
|
@@ -450,6 +451,11 @@ export async function completeWorkspaceSetup(args) {
|
|
|
450
451
|
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
451
452
|
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
452
453
|
const marker = installMarker({ workspaceId, scope: "local", launcher, quiet: true });
|
|
454
|
+
// RTSC-780: and the once-per-machine half. Idempotent by construction (each adapter
|
|
455
|
+
// replaces its own entry), so running it on every bind is how a harness installed
|
|
456
|
+
// AFTER the first bind gets picked up without anyone having to know that `retasc
|
|
457
|
+
// setup` exists. Quiet: the receipt below names what it wired, in one row.
|
|
458
|
+
const wired = runSetup({ launcher, quiet: true });
|
|
453
459
|
// Confirm the binding the same way the agent will see it, and print it as the receipt
|
|
454
460
|
// (RTSC-673). Resolved rather than echoed from what we just sent: this is the last
|
|
455
461
|
// chance to notice a folder bound to something other than what was asked for, and a
|
|
@@ -470,6 +476,15 @@ export async function completeWorkspaceSetup(args) {
|
|
|
470
476
|
{ label: "Project", value: bound?.name ? `${pfx} ${DOT} ${bound.name}` : pfx },
|
|
471
477
|
{ label: "Agent key", value: `${minted.key.slice(0, 14)}…`, note: "never leaves ~/.retasc" },
|
|
472
478
|
{ label: "MCP wired", value: marker.where, note: "secret-free, safe to commit" },
|
|
479
|
+
...(wired.wired.length
|
|
480
|
+
? [
|
|
481
|
+
{
|
|
482
|
+
label: "Harnesses",
|
|
483
|
+
value: wired.wired.map((w) => w.label).join(", "),
|
|
484
|
+
note: "every folder",
|
|
485
|
+
},
|
|
486
|
+
]
|
|
487
|
+
: []),
|
|
473
488
|
], `Agents launched here can only ever read or write ${pfx}.`));
|
|
474
489
|
// Only when we could write it nowhere. See printMarkerBlock.
|
|
475
490
|
if (!marker.wroteFile && marker.where === "")
|
|
@@ -568,6 +583,12 @@ export async function setupFromToken(token, opts, deps = {}) {
|
|
|
568
583
|
createdAt: Date.now(),
|
|
569
584
|
});
|
|
570
585
|
(deps.marker ?? installMarker)({ workspaceId, scope: "local", launcher });
|
|
586
|
+
// RTSC-780 — same once-per-machine wiring on the agent door. This path has no TTY and
|
|
587
|
+
// no receipt card, so it prints its own line rather than staying silent.
|
|
588
|
+
const wired = (deps.setup ?? runSetup)({ launcher, quiet: true });
|
|
589
|
+
if (wired.wired.length) {
|
|
590
|
+
console.log(`✓ Retasc wired into ${wired.wired.map((w) => w.label).join(", ")}.`);
|
|
591
|
+
}
|
|
571
592
|
// RTSC-532 — name the PATH, not "this folder". A binding is folder → project, and
|
|
572
593
|
// until this line said which folder, a wrong one was undetectable: the agent still
|
|
573
594
|
// calls in, so the Dash shows success while the folder she actually works in has no
|
package/dist/commands/mcp.js
CHANGED
|
@@ -161,6 +161,34 @@ export function writeProjectMcpJson(entry) {
|
|
|
161
161
|
}
|
|
162
162
|
return path;
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Say plainly that `--runtime` labels an agent and does not choose a harness (RTSC-781).
|
|
166
|
+
*
|
|
167
|
+
* The flag reads as a promise. It is offered on the same commands that install MCP
|
|
168
|
+
* config, it is documented with a list of harness names, and before RTSC-780 the only
|
|
169
|
+
* thing any of those commands could write was Claude Code. So
|
|
170
|
+
* `key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing
|
|
171
|
+
* — and the resulting failure surfaced as "signed in, but the server is unreachable",
|
|
172
|
+
* which sends you to check a server that is fine and serving other clients.
|
|
173
|
+
*
|
|
174
|
+
* The commands that call this mint a key and write a KEY-BEARING entry, which is
|
|
175
|
+
* deliberately per-folder and Claude-Code-only: a key must never be written into a
|
|
176
|
+
* machine-global config (RTSC-91). So the honest answer is not to wire the named harness
|
|
177
|
+
* here, it is to say that this command cannot and name the one that can.
|
|
178
|
+
*
|
|
179
|
+
* A note rather than a refusal. By the time this prints, the key is minted and the
|
|
180
|
+
* Claude Code wiring is real work someone asked for; exiting would throw that away to
|
|
181
|
+
* make a point. Silence was the bug, not the wiring.
|
|
182
|
+
*/
|
|
183
|
+
export function noteRuntimeIsALabel(runtime) {
|
|
184
|
+
const r = (runtime ?? "").trim();
|
|
185
|
+
if (!r || r === "claude-code")
|
|
186
|
+
return;
|
|
187
|
+
console.log(`Note: --runtime ${r} sets this agent's LABEL in the Dash. It does not choose where\n` +
|
|
188
|
+
`config is written. This command writes a key-bearing entry for THIS folder, and a\n` +
|
|
189
|
+
`key only ever belongs in Claude Code's own config, never in a machine-wide one.\n` +
|
|
190
|
+
`To wire ${r}, run \`retasc setup\` — secret-free, every harness, every folder.\n`);
|
|
191
|
+
}
|
|
164
192
|
/**
|
|
165
193
|
* Wire the Retasc MCP server into the user's agent. Prefers the `claude` CLI;
|
|
166
194
|
* falls back to writing ./.mcp.json. Always prints the manual block so the user
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// RTSC-780: `retasc setup` — wire every MCP harness on this machine, once.
|
|
2
|
+
//
|
|
3
|
+
// The shape this exists to deliver:
|
|
4
|
+
// once per machine `retasc setup`, and every harness that is installed gets an entry
|
|
5
|
+
// once per project `retasc bind`, and every one of them sees it
|
|
6
|
+
//
|
|
7
|
+
// Nothing harness-specific after that, ever again. The entry written here names no
|
|
8
|
+
// workspace (keystore.AUTO_WORKSPACE), so it is correct in every folder: the proxy
|
|
9
|
+
// resolves the directory it was spawned in against the home keystore at startup. The
|
|
10
|
+
// folder still decides the org, which is the whole routing model (RTSC-91) and the thing
|
|
11
|
+
// a machine-global entry would otherwise break.
|
|
12
|
+
//
|
|
13
|
+
// NOT an npm postinstall hook. Most people reach this CLI through `npx`, which installs
|
|
14
|
+
// nothing (the reason `resolveLauncher` exists, RTSC-493), so a postinstall would never
|
|
15
|
+
// run for them; and writing into somebody's global agent configs from a package install
|
|
16
|
+
// is a thing to do in front of a human, not behind one.
|
|
17
|
+
import { AUTO_WORKSPACE } from "../lib/keystore.js";
|
|
18
|
+
import { HARNESSES, detectHarnesses, tildePath } from "../lib/harness.js";
|
|
19
|
+
import { resolveLauncher, launcherNote } from "../lib/launcher.js";
|
|
20
|
+
import { VERSION } from "../version.js";
|
|
21
|
+
import { card } from "../lib/card.js";
|
|
22
|
+
/** The one entry every harness gets: the watchdog proxy, resolving its own folder. */
|
|
23
|
+
export function autoMarkerEntry(launcher) {
|
|
24
|
+
return {
|
|
25
|
+
command: launcher.command,
|
|
26
|
+
args: [...launcher.args, "mcp-proxy"],
|
|
27
|
+
env: { RETASC_WORKSPACE: AUTO_WORKSPACE },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wire the auto marker into every detected harness.
|
|
32
|
+
*
|
|
33
|
+
* `quiet` is for the call at the end of `bind`, where the bind receipt does the talking.
|
|
34
|
+
*/
|
|
35
|
+
export function runSetup(opts) {
|
|
36
|
+
const all = opts.harnesses;
|
|
37
|
+
const resolved = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
|
|
38
|
+
if (!opts.launcher) {
|
|
39
|
+
const note = launcherNote(resolved);
|
|
40
|
+
if (note)
|
|
41
|
+
console.log(note);
|
|
42
|
+
}
|
|
43
|
+
const entry = autoMarkerEntry(resolved.launcher);
|
|
44
|
+
const known = all ?? HARNESSES;
|
|
45
|
+
const present = detectHarnesses(known);
|
|
46
|
+
const presentIds = new Set(present.map((h) => h.id));
|
|
47
|
+
const result = {
|
|
48
|
+
wired: [],
|
|
49
|
+
failed: [],
|
|
50
|
+
absent: known.filter((h) => !presentIds.has(h.id)).map((h) => h.label),
|
|
51
|
+
};
|
|
52
|
+
for (const h of present) {
|
|
53
|
+
const outcome = h.install(entry);
|
|
54
|
+
if (outcome.ok) {
|
|
55
|
+
result.wired.push({ label: h.label, where: tildePath(outcome.path), replaced: outcome.replaced });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
result.failed.push({ label: h.label, reason: outcome.reason });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!opts.quiet)
|
|
62
|
+
printSetup(result);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/** The receipt. One row per harness, so "what did this just touch" needs no guessing. */
|
|
66
|
+
export function printSetup(r) {
|
|
67
|
+
if (!r.wired.length && !r.failed.length) {
|
|
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.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
console.log("\n" +
|
|
74
|
+
card(`✓ Retasc wired into ${r.wired.length} ${r.wired.length === 1 ? "harness" : "harnesses"}`, r.wired.map((w) => ({
|
|
75
|
+
label: w.label,
|
|
76
|
+
value: w.where,
|
|
77
|
+
note: w.replaced ? "updated" : "new",
|
|
78
|
+
}))));
|
|
79
|
+
for (const f of r.failed)
|
|
80
|
+
console.log(` ! ${f.label}: ${f.reason}`);
|
|
81
|
+
if (r.absent.length)
|
|
82
|
+
console.log(` Not on this machine: ${r.absent.join(", ")}.`);
|
|
83
|
+
console.log("\nThese entries carry no key and name no project, so they are correct in every folder.\n" +
|
|
84
|
+
"Run `retasc bind` in a project to say which org it belongs to, then start your agent\n" +
|
|
85
|
+
"there (or restart it if it's already open).");
|
|
86
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { api } from "../api.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// RTSC-749 — `retasc triage`. Epic RTSC-751.
|
|
5
|
+
//
|
|
6
|
+
// The second human signing surface for the quarantine gate (RTSC-746): external intake is
|
|
7
|
+
// undispatchable until a member reads it and signs it, and plenty of people live in a
|
|
8
|
+
// terminal rather than in the Dash.
|
|
9
|
+
//
|
|
10
|
+
// THE HARD PART, and why this file is careful. The Dash is a browser a human is sitting
|
|
11
|
+
// in. The CLI is a binary on a machine where a CODING AGENT CAN SHELL OUT. If approval
|
|
12
|
+
// were scriptable, an agent that just read a hostile body could approve the next one, and
|
|
13
|
+
// the gate it is standing in front of would protect nothing.
|
|
14
|
+
//
|
|
15
|
+
// The server-side invariant does most of the work: `convex/triage.ts` authenticates
|
|
16
|
+
// through Convex Auth and rejects agent API keys outright, so an agent holding only a
|
|
17
|
+
// workspace MCP key cannot sign, full stop. What remains is an agent shelling out on a
|
|
18
|
+
// machine where a human is already logged into the CLI. These are the client-side
|
|
19
|
+
// defences against that, and they RAISE THE BAR rather than close the hole:
|
|
20
|
+
//
|
|
21
|
+
// • Refuse a non-interactive stdin/stdout. A harnessed `exec` has no PTY.
|
|
22
|
+
// • No approve flag. There is no `--approve`, no `--yes`, no `--force` — so there is no
|
|
23
|
+
// one-liner to smuggle into a command, and no "just add --yes" for anyone to suggest.
|
|
24
|
+
// • Render the body, then require the identifier RETYPED. Not a y/N: a typed value an
|
|
25
|
+
// agent would have to have read the screen to produce.
|
|
26
|
+
// • Echo the content hash of the body just printed, so the server refuses if the text
|
|
27
|
+
// moved under the reader.
|
|
28
|
+
//
|
|
29
|
+
// RESIDUAL RISK, stated rather than hidden: an agent puppeting a real PTY on a machine
|
|
30
|
+
// with a logged-in CLI defeats the TTY check, and the server cannot tell that apart from
|
|
31
|
+
// a human. The mitigation is revocation (`retasc logout`), and the Dash remains the
|
|
32
|
+
// recommended surface. This is said in the command's own help text too — a defence
|
|
33
|
+
// somebody believes is stronger than it is has already done its damage.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Is a real person at this terminal?
|
|
37
|
+
*
|
|
38
|
+
* BOTH streams, deliberately. `stdin` alone is the usual check and it is not enough here:
|
|
39
|
+
* a harness that pipes output while leaving stdin attached would still be driving a
|
|
40
|
+
* command whose entire safety story is "a human is reading the screen".
|
|
41
|
+
*/
|
|
42
|
+
function interactive() {
|
|
43
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
44
|
+
}
|
|
45
|
+
/** The SHA-256 the server binds a signature to. Must match `externalContentHash` in
|
|
46
|
+
* convex/lib/quarantine.ts EXACTLY, including the length prefixes — a drift here means
|
|
47
|
+
* every terminal approval fails with CONTENT_CHANGED on text nobody edited. */
|
|
48
|
+
async function contentHash(title, body) {
|
|
49
|
+
const canonical = `${title.length}:${title}${body.length}:${body}`;
|
|
50
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
51
|
+
return Array.from(new Uint8Array(digest))
|
|
52
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
53
|
+
.join("");
|
|
54
|
+
}
|
|
55
|
+
/** Resolve the org the same way `retasc billing` does: explicit, or the only one. */
|
|
56
|
+
async function resolveOrg(orgId) {
|
|
57
|
+
const me = await api.me();
|
|
58
|
+
if (!orgId) {
|
|
59
|
+
if (me.orgs.length === 0)
|
|
60
|
+
throw new Error("You're not a member of any org yet.");
|
|
61
|
+
if (me.orgs.length > 1) {
|
|
62
|
+
const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
|
|
63
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
|
|
64
|
+
}
|
|
65
|
+
orgId = me.orgs[0].id;
|
|
66
|
+
}
|
|
67
|
+
const org = me.orgs.find((o) => o.id === orgId);
|
|
68
|
+
return { orgId: orgId, label: org ? `${org.name}${org.slug ? ` (${org.slug})` : ""}` : String(orgId) };
|
|
69
|
+
}
|
|
70
|
+
/** `retasc triage` — what is waiting. A LIST ONLY: nothing here approves. */
|
|
71
|
+
export async function triageListAction(opts) {
|
|
72
|
+
const { orgId, label } = await resolveOrg(opts.orgId);
|
|
73
|
+
const res = await api.listQuarantined({ orgId });
|
|
74
|
+
const items = res.items ?? [];
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
console.log(JSON.stringify({ orgId, items, hasMore: res.hasMore }, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
console.log(`Nothing waiting in ${label}.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(`${items.length} ${items.length === 1 ? "issue" : "issues"} filed from outside ${label}, ` +
|
|
84
|
+
`waiting for you to read and approve ${items.length === 1 ? "it" : "them"}:\n`);
|
|
85
|
+
for (const i of items) {
|
|
86
|
+
const who = i.externalAuthor ?? "an unidentified account";
|
|
87
|
+
console.log(` ${i.identifier} ${i.title}`);
|
|
88
|
+
console.log(` by ${who}${i.sourceUrl ? ` · ${i.sourceUrl}` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
if (res.hasMore)
|
|
91
|
+
console.log(`\n …and more. Clear some and run this again.`);
|
|
92
|
+
// The list deliberately does NOT print bodies. Reading happens one at a time, next to
|
|
93
|
+
// the decision — a wall of concatenated external text is exactly the thing people skim.
|
|
94
|
+
console.log(`\nRead one in full, then approve or reject: retasc triage <ISSUE-ID>`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `retasc triage RTSC-42` — render the full body, then ask.
|
|
98
|
+
*
|
|
99
|
+
* The whole command is the reading. Everything before the prompt exists to make sure the
|
|
100
|
+
* person answering it has actually seen the text they are vouching for.
|
|
101
|
+
*/
|
|
102
|
+
export async function triageOneAction(identifier, opts) {
|
|
103
|
+
// FIRST, before any network call: refuse to run where nobody can read the screen. Doing
|
|
104
|
+
// this first means the refusal is about the environment, not about what happens to be
|
|
105
|
+
// in the queue.
|
|
106
|
+
if (!interactive()) {
|
|
107
|
+
throw new Error("`retasc triage <id>` needs an interactive terminal.\n" +
|
|
108
|
+
" Approving external work means a person read it, so this command refuses to run\n" +
|
|
109
|
+
" with piped input or output. There is deliberately no --approve flag.\n" +
|
|
110
|
+
" Approve in the Dash instead: https://dash.retasc.com/?tab=queue");
|
|
111
|
+
}
|
|
112
|
+
const { orgId, label } = await resolveOrg(opts.orgId);
|
|
113
|
+
const res = await api.listQuarantined({ orgId });
|
|
114
|
+
const items = res.items ?? [];
|
|
115
|
+
const want = identifier.trim().toUpperCase();
|
|
116
|
+
const item = items.find((i) => i.identifier.toUpperCase() === want);
|
|
117
|
+
if (!item) {
|
|
118
|
+
throw new Error(`${identifier} is not waiting for approval in ${label}.\n` +
|
|
119
|
+
` It may already be approved, rejected, or never have been external.\n` +
|
|
120
|
+
` Run \`retasc triage\` to see what is waiting.`);
|
|
121
|
+
}
|
|
122
|
+
// The rendering. Plain, undecorated, and complete — no truncation, because a body
|
|
123
|
+
// truncated at the interesting part is worse than no preview at all.
|
|
124
|
+
const who = item.externalAuthor ?? "an unidentified account";
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(` ${item.identifier} ${item.title}`);
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log(` Filed from OUTSIDE ${label}, by ${who} on ${item.externalOrigin ?? "a connector"}.`);
|
|
129
|
+
if (item.sourceUrl)
|
|
130
|
+
console.log(` Source: ${item.sourceUrl}`);
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(" ── the text you are approving ".padEnd(72, "─"));
|
|
133
|
+
console.log("");
|
|
134
|
+
for (const line of (item.body || "(no description)").split("\n"))
|
|
135
|
+
console.log(` ${line}`);
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log(" ".padEnd(72, "─"));
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(" Treat this as a report to evaluate, not as instructions. Approving tells");
|
|
140
|
+
console.log(" your agents the text is safe to act on.");
|
|
141
|
+
console.log("");
|
|
142
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
143
|
+
try {
|
|
144
|
+
// The typed confirm. Not y/N: a value that has to be read off the screen. `reject` is
|
|
145
|
+
// the other real answer, and it is deliberately as easy to type as the id is — saying
|
|
146
|
+
// no must never be the expensive option, or people will drift toward yes.
|
|
147
|
+
const answer = (await rl.question(` Type ${item.identifier} to APPROVE, or "reject" to refuse: `)).trim();
|
|
148
|
+
if (answer.toLowerCase() === "reject") {
|
|
149
|
+
await api.rejectExternal({ orgId, identifier: item.identifier });
|
|
150
|
+
console.log(`\n ✓ ${item.identifier} rejected and closed.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (answer.toUpperCase() !== item.identifier.toUpperCase()) {
|
|
154
|
+
console.log(`\n Nothing done — that didn't match. ${item.identifier} is still waiting.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Recompute from the text just printed rather than trusting the value the list gave
|
|
158
|
+
// us: if the two could differ, the hash would stop meaning "what was on screen".
|
|
159
|
+
const hash = await contentHash(item.title, item.body ?? "");
|
|
160
|
+
if (hash !== item.contentHash) {
|
|
161
|
+
// Belt and braces — the server will refuse anyway. Saying it here is friendlier
|
|
162
|
+
// than a CONTENT_CHANGED from the backend.
|
|
163
|
+
throw new Error(`${item.identifier} changed while you were reading it. Run the command again.`);
|
|
164
|
+
}
|
|
165
|
+
await api.approveExternal({ orgId, identifier: item.identifier, contentHash: hash });
|
|
166
|
+
console.log(`\n ✓ ${item.identifier} approved. Agents can pick it up now.`);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
rl.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import { Command } from "commander";
|
|
|
3
3
|
import { VERSION } from "./version.js";
|
|
4
4
|
import { selfCommand, versionStamp } from "./lib/launcher.js";
|
|
5
5
|
import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
|
|
6
|
-
import { installMcp, normalizeScope } from "./commands/mcp.js";
|
|
6
|
+
import { installMcp, noteRuntimeIsALabel, normalizeScope } from "./commands/mcp.js";
|
|
7
|
+
import { runSetup } from "./commands/setup.js";
|
|
7
8
|
import { installGate, resolveGatePrefix } from "./commands/gate.js";
|
|
8
9
|
import { claimAction, releaseAction } from "./commands/claim.js";
|
|
9
10
|
import { bindAction, setupFromToken } from "./commands/bind.js";
|
|
@@ -14,6 +15,7 @@ import { identityAction } from "./commands/identity.js";
|
|
|
14
15
|
import { importAction } from "./commands/import.js";
|
|
15
16
|
import { doctorAction } from "./commands/doctor.js";
|
|
16
17
|
import { billingAction } from "./commands/billing.js";
|
|
18
|
+
import { triageListAction, triageOneAction } from "./commands/triage.js";
|
|
17
19
|
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
18
20
|
import { whoamiView, orgCreatedView, projectCreatedView, keyListView, inviteListView, } from "./lib/format.js";
|
|
19
21
|
import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
@@ -143,7 +145,7 @@ program
|
|
|
143
145
|
.requiredOption("--project <name>", "Project name")
|
|
144
146
|
.requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
|
|
145
147
|
.option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
|
|
146
|
-
.option("--runtime <runtime>", "
|
|
148
|
+
.option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
|
|
147
149
|
.option("--scope <scope>", "MCP install scope: local | project (per-folder only)", "local")
|
|
148
150
|
.option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog")
|
|
149
151
|
.action(async (opts) => {
|
|
@@ -171,6 +173,7 @@ program
|
|
|
171
173
|
// is a per-folder act. (The prefix is kept only as a gate convenience.)
|
|
172
174
|
const cfg = patchConfig({ defaultProjectPrefix: project.prefix });
|
|
173
175
|
console.log("");
|
|
176
|
+
noteRuntimeIsALabel(opts.runtime);
|
|
174
177
|
installMcp({ url: cfg.mcpUrl, key: minted.key, scope: normalizeScope(opts.scope), watchdog: opts.watchdog });
|
|
175
178
|
}
|
|
176
179
|
catch (e) {
|
|
@@ -189,7 +192,7 @@ program
|
|
|
189
192
|
.option("--project <name>", "Create a new project with this name (with --prefix)")
|
|
190
193
|
.option("--prefix <PREFIX>", "Prefix for a new project, e.g. ACME")
|
|
191
194
|
.option("--agent <name>", "Agent member name (default: auto)")
|
|
192
|
-
.option("--runtime <runtime>", "
|
|
195
|
+
.option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
|
|
193
196
|
.option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
|
|
194
197
|
// RTSC-523 — decline the global install without a TTY. `bind`/`join` ask when a human
|
|
195
198
|
// is present; this is how a scripted run, or a developer whose own agent runs setup,
|
|
@@ -253,6 +256,27 @@ program
|
|
|
253
256
|
requireLogin();
|
|
254
257
|
await billingAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
255
258
|
});
|
|
259
|
+
// RTSC-749 — the terminal half of the quarantine gate (RTSC-746). Sits beside `billing`
|
|
260
|
+
// rather than under `issue`: the `issue` commands go over the workspace MCP key, and this
|
|
261
|
+
// one must go over the HUMAN's session — an agent key cannot sign, by design.
|
|
262
|
+
program
|
|
263
|
+
.command("triage")
|
|
264
|
+
.argument("[issue]", "The issue to read and decide on. Omit to list what is waiting.")
|
|
265
|
+
.description("Read and approve work filed from OUTSIDE your org. Agents can't pick these up until " +
|
|
266
|
+
"a person approves them, and a person means you: this needs an interactive terminal " +
|
|
267
|
+
"and there is deliberately no --approve flag. The Dash is the recommended surface " +
|
|
268
|
+
"(an agent that can drive a real PTY on this machine could drive this command too).")
|
|
269
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
270
|
+
.option("--json", "List as raw JSON. Listing only — deciding is never scriptable.")
|
|
271
|
+
.action(async (issue, opts) => {
|
|
272
|
+
requireLogin();
|
|
273
|
+
if (issue === undefined) {
|
|
274
|
+
await triageListAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
await triageOneAction(issue, { orgId: opts.orgId }).catch(fail);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
256
280
|
// --- org / project ---------------------------------------------------------
|
|
257
281
|
const org = program.command("org").description("Manage orgs.");
|
|
258
282
|
org
|
|
@@ -316,7 +340,7 @@ key
|
|
|
316
340
|
.requiredOption("--org-id <id>")
|
|
317
341
|
.requiredOption("--project-id <id>")
|
|
318
342
|
.option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
|
|
319
|
-
.option("--runtime <runtime>", "
|
|
343
|
+
.option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
|
|
320
344
|
.option("--name <label>", "Key label")
|
|
321
345
|
.option("--install", "Also wire the key into your agent via MCP")
|
|
322
346
|
.option("--scope <scope>", "MCP install scope if --install: local | project", "local")
|
|
@@ -335,6 +359,7 @@ key
|
|
|
335
359
|
if (opts.install) {
|
|
336
360
|
const cfg = loadConfig();
|
|
337
361
|
console.log("");
|
|
362
|
+
noteRuntimeIsALabel(opts.runtime);
|
|
338
363
|
installMcp({ url: cfg.mcpUrl, key: res.key, scope: normalizeScope(opts.scope), watchdog: true });
|
|
339
364
|
}
|
|
340
365
|
}
|
|
@@ -471,7 +496,7 @@ program
|
|
|
471
496
|
.option("--no-bind", "Redeem only — don't set this folder up")
|
|
472
497
|
.option("--project-id <id>", "Which project to bind to (skips the picker)")
|
|
473
498
|
.option("--agent <name>", "Agent member name (default: auto)")
|
|
474
|
-
.option("--runtime <runtime>", "
|
|
499
|
+
.option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
|
|
475
500
|
// RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
|
|
476
501
|
// it, so the flag that causes the gap is the right place to say how to close it.
|
|
477
502
|
.option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
|
|
@@ -519,6 +544,17 @@ program
|
|
|
519
544
|
await importAction({ orgId: opts.orgId, source: opts.source, yes: opts.yes }).catch(fail);
|
|
520
545
|
});
|
|
521
546
|
// --- mcp wiring ------------------------------------------------------------
|
|
547
|
+
// RTSC-780 — the once-per-machine half of setup. `bind` runs this itself the first
|
|
548
|
+
// time, so most people never type it; it exists on its own for the second harness
|
|
549
|
+
// installed after a bind, and for re-running after an upgrade moved the launcher.
|
|
550
|
+
program
|
|
551
|
+
.command("setup")
|
|
552
|
+
.description("Detect the MCP harnesses on this machine and wire Retasc into each, once.")
|
|
553
|
+
.option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
|
|
554
|
+
.allowExcessArguments(false)
|
|
555
|
+
.action((opts) => {
|
|
556
|
+
runSetup({ install: opts.install });
|
|
557
|
+
});
|
|
522
558
|
const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
|
|
523
559
|
mcp
|
|
524
560
|
.command("install")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// RTSC-780: the harness registry. One entry per MCP client we know how to wire.
|
|
2
|
+
//
|
|
3
|
+
// Before this file the CLI could wire exactly one harness: `installMcp`/`installMarker`
|
|
4
|
+
// spawned `claude mcp add` and, if that failed, wrote ./.mcp.json. Everything else got
|
|
5
|
+
// nothing, which is why Codex had never worked in any project on any machine. The only
|
|
6
|
+
// non-Claude binding that ever existed here was hand-written into ~/.grok/config.toml by
|
|
7
|
+
// an agent, machine-global with an inline API key — the exact cross-org leak `normalizeScope`
|
|
8
|
+
// refuses to allow for Claude Code.
|
|
9
|
+
//
|
|
10
|
+
// The entry we write is the `auto` marker (keystore.AUTO_WORKSPACE): no key, no workspace
|
|
11
|
+
// id, nothing folder-specific. That is what makes ONE write per harness enough for every
|
|
12
|
+
// project, and it is only safe because the harnesses verified below spawn stdio servers
|
|
13
|
+
// with cwd set to the project directory, so the proxy can resolve the folder itself.
|
|
14
|
+
//
|
|
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.
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
/**
|
|
25
|
+
* The home directory harness configs are resolved under.
|
|
26
|
+
*
|
|
27
|
+
* `RETASC_HOME` overrides it, the same escape hatch `keystore.ts` gives with
|
|
28
|
+
* `RETASC_DIR` and for the same reason: without one, anything that exercises this
|
|
29
|
+
* module writes into the developer's REAL global agent configs. That is not
|
|
30
|
+
* hypothetical — it happened while building this, and the tell was `retasc setup`
|
|
31
|
+
* reporting "replaced" on a Codex config that had never held a retasc entry.
|
|
32
|
+
* A test that can silently reconfigure the machine it runs on is a worse bug than
|
|
33
|
+
* whatever it was testing.
|
|
34
|
+
*/
|
|
35
|
+
function home() {
|
|
36
|
+
return process.env.RETASC_HOME || homedir();
|
|
37
|
+
}
|
|
38
|
+
/** `/Users/me/.codex/config.toml` → `~/.codex/config.toml`, for receipts. The card's
|
|
39
|
+
* value column is 20 columns wide, and an absolute path ellipses away to nothing
|
|
40
|
+
* useful exactly where the reader is checking WHICH file we touched. */
|
|
41
|
+
export function tildePath(p) {
|
|
42
|
+
const h = home();
|
|
43
|
+
return p.startsWith(h + "/") ? `~${p.slice(h.length)}` : p;
|
|
44
|
+
}
|
|
45
|
+
export const SERVER_NAME = "retasc";
|
|
46
|
+
// --- shared helpers ---------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* True when `bin` resolves on PATH.
|
|
49
|
+
*
|
|
50
|
+
* This DOES run a shell (`command -v` is a shell builtin, not an executable), so the
|
|
51
|
+
* argument is concatenated into a command string and a name carrying `;` or a space
|
|
52
|
+
* would be a command injection. Every caller passes a literal from HARNESSES below,
|
|
53
|
+
* but "the only caller today is safe" is how that stops being true, so the shape of
|
|
54
|
+
* the name is checked here rather than trusted.
|
|
55
|
+
*/
|
|
56
|
+
export function onPath(bin) {
|
|
57
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(bin))
|
|
58
|
+
return false;
|
|
59
|
+
const r = spawnSync("command", ["-v", bin], { encoding: "utf8", shell: "/bin/sh" });
|
|
60
|
+
return r.status === 0 && Boolean((r.stdout || "").trim());
|
|
61
|
+
}
|
|
62
|
+
function readIfExists(path) {
|
|
63
|
+
try {
|
|
64
|
+
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Write, creating the parent directory. Config files can hold nothing secret by
|
|
71
|
+
* design (the marker is secret-free), so no 0600 dance is needed here — but we
|
|
72
|
+
* also never widen an existing file's mode, so an existing 0600 config stays 0600. */
|
|
73
|
+
function writeConfig(path, text) {
|
|
74
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
75
|
+
writeFileSync(path, text, "utf8");
|
|
76
|
+
}
|
|
77
|
+
// --- TOML (Codex, Grok) -----------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Splice one table (and its sub-tables) into a TOML document, in place.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately textual rather than parse-and-reserialize. These are the user's own
|
|
82
|
+
* global config files: they hold comments, hand-chosen ordering, and settings that
|
|
83
|
+
* have nothing to do with us. A round-trip through a TOML parser would silently
|
|
84
|
+
* rewrite all of that, and the CLI ships with two dependencies (commander, convex)
|
|
85
|
+
* precisely so it stays cheap to install — adding a TOML library to reformat
|
|
86
|
+
* somebody's config is the wrong trade twice over.
|
|
87
|
+
*
|
|
88
|
+
* Replaces every existing `[prefix]` / `[prefix.*]` table, then appends `block`.
|
|
89
|
+
* A table ends at the next line that starts a different table at column 0.
|
|
90
|
+
*/
|
|
91
|
+
export function spliceTomlTable(text, prefix, block) {
|
|
92
|
+
const lines = text.split("\n");
|
|
93
|
+
const out = [];
|
|
94
|
+
let skipping = false;
|
|
95
|
+
const opens = (line) => {
|
|
96
|
+
const m = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/);
|
|
97
|
+
return m ? m[1].trim() : null;
|
|
98
|
+
};
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const table = opens(line);
|
|
101
|
+
if (table !== null) {
|
|
102
|
+
// A new table header always ends whatever we were skipping.
|
|
103
|
+
skipping = table === prefix || table.startsWith(`${prefix}.`);
|
|
104
|
+
}
|
|
105
|
+
if (!skipping)
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
// Collapse the blank-line run the removal may have left at the end, so repeated
|
|
109
|
+
// installs don't grow the file by one newline every time.
|
|
110
|
+
while (out.length && out[out.length - 1].trim() === "")
|
|
111
|
+
out.pop();
|
|
112
|
+
const body = out.join("\n");
|
|
113
|
+
return `${body}\n\n${block.trim()}\n`;
|
|
114
|
+
}
|
|
115
|
+
/** The `[mcp_servers.retasc]` block, in the shape Codex and Grok both accept. */
|
|
116
|
+
export function tomlBlock(entry) {
|
|
117
|
+
const arr = entry.args.map((a) => JSON.stringify(a)).join(", ");
|
|
118
|
+
const env = Object.entries(entry.env)
|
|
119
|
+
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
return [
|
|
122
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
123
|
+
`command = ${JSON.stringify(entry.command)}`,
|
|
124
|
+
`args = [${arr}]`,
|
|
125
|
+
`enabled = true`,
|
|
126
|
+
``,
|
|
127
|
+
`[mcp_servers.${SERVER_NAME}.env]`,
|
|
128
|
+
env,
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
function tomlHarness(id, label, path, bin) {
|
|
132
|
+
return {
|
|
133
|
+
id,
|
|
134
|
+
label,
|
|
135
|
+
configPath: path,
|
|
136
|
+
detect: () => existsSync(path()) || onPath(bin),
|
|
137
|
+
install(entry) {
|
|
138
|
+
const p = path();
|
|
139
|
+
const before = readIfExists(p);
|
|
140
|
+
try {
|
|
141
|
+
writeConfig(p, spliceTomlTable(before, `mcp_servers.${SERVER_NAME}`, tomlBlock(entry)));
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return { ok: false, reason: String(e?.message ?? e) };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, path: p, replaced: /^\s*\[\[?mcp_servers\.retasc\b/m.test(before) };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
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);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
doc = {};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
doc.mcpServers = doc.mcpServers ?? {};
|
|
164
|
+
doc.mcpServers[SERVER_NAME] = entry;
|
|
165
|
+
return JSON.stringify(doc, null, 2) + "\n";
|
|
166
|
+
}
|
|
167
|
+
// --- Claude Code ------------------------------------------------------------
|
|
168
|
+
/**
|
|
169
|
+
* Claude Code is the one harness with a real CLI for this, and `claude mcp add` is
|
|
170
|
+
* the ONLY sanctioned way to edit `~/.claude.json`: a running Claude Code rewrites
|
|
171
|
+
* that file constantly, so a read-modify-write from here would race it and could
|
|
172
|
+
* drop unrelated servers.
|
|
173
|
+
*
|
|
174
|
+
* Scope `user` here, which `normalizeScope` refuses everywhere else (RTSC-91). The
|
|
175
|
+
* refusal is about a global entry that NAMES an org — an id or a key in a
|
|
176
|
+
* machine-wide file routes every folder to one project. The `auto` marker names
|
|
177
|
+
* nothing, so the objection does not apply to it, and the whole point of RTSC-780
|
|
178
|
+
* is that this entry is written once and serves every folder.
|
|
179
|
+
*/
|
|
180
|
+
const claudeCode = {
|
|
181
|
+
id: "claude-code",
|
|
182
|
+
label: "Claude Code",
|
|
183
|
+
configPath: () => join(home(), ".claude.json"),
|
|
184
|
+
// A test home means "do not touch this machine's real config". `claude mcp add`
|
|
185
|
+
// writes ~/.claude.json wherever HOME points, so the override has to gate DETECTION,
|
|
186
|
+
// not just the path: an undetected harness is never installed into.
|
|
187
|
+
detect: () => !process.env.RETASC_HOME && onPath("claude"),
|
|
188
|
+
install(entry) {
|
|
189
|
+
const args = [
|
|
190
|
+
"mcp", "add",
|
|
191
|
+
SERVER_NAME,
|
|
192
|
+
"--transport", "stdio",
|
|
193
|
+
"--scope", "user",
|
|
194
|
+
...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
195
|
+
"--", entry.command, ...entry.args,
|
|
196
|
+
];
|
|
197
|
+
// An existing user-scope entry makes `add` fail rather than replace, so clear it
|
|
198
|
+
// first. Best effort: a missing entry exits non-zero and that is fine.
|
|
199
|
+
//
|
|
200
|
+
// This does open a remove-then-fail window: if `add` fails after a successful
|
|
201
|
+
// remove, the user is left with no retasc entry rather than the one they had. The
|
|
202
|
+
// blast radius is bounded to OUR entry (the name is fixed, and `-s user` scopes it),
|
|
203
|
+
// the outcome is reported rather than swallowed, and `retasc setup` re-runs cleanly.
|
|
204
|
+
// Restoring the previous entry would mean reading ~/.claude.json ourselves, which is
|
|
205
|
+
// the one thing that file must never have done to it: a running Claude Code rewrites
|
|
206
|
+
// it constantly and the read-modify-write would race it.
|
|
207
|
+
const had = spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "user"], {
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
});
|
|
210
|
+
const r = spawnSync("claude", args, { encoding: "utf8" });
|
|
211
|
+
if (r.error)
|
|
212
|
+
return { ok: false, reason: r.error.message };
|
|
213
|
+
if (r.status !== 0) {
|
|
214
|
+
const msg = (r.stderr || r.stdout || "").trim().split("\n")[0] || `exited ${r.status}`;
|
|
215
|
+
return { ok: false, reason: msg };
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, path: "Claude Code (user scope)", replaced: had.status === 0 };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
// --- the registry -----------------------------------------------------------
|
|
221
|
+
export const HARNESSES = [
|
|
222
|
+
claudeCode,
|
|
223
|
+
tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"),
|
|
224
|
+
tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok"),
|
|
225
|
+
];
|
|
226
|
+
/** Every harness actually present on this machine. */
|
|
227
|
+
export function detectHarnesses(list = HARNESSES) {
|
|
228
|
+
return list.filter((h) => {
|
|
229
|
+
try {
|
|
230
|
+
return h.detect();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
package/dist/lib/keystore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
/** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
|
|
@@ -52,12 +52,87 @@ export function removeBinding(workspaceId) {
|
|
|
52
52
|
export function newWorkspaceId() {
|
|
53
53
|
return `ws_${randomUUID()}`;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* RTSC-780: the sentinel that means "work out the workspace from where you were
|
|
57
|
+
* spawned", rather than naming one.
|
|
58
|
+
*
|
|
59
|
+
* A marker carrying a real `ws_…` id names ONE workspace, so an entry holding it
|
|
60
|
+
* is only ever correct in the folder it was written for. That is why there is no
|
|
61
|
+
* global scope today (RTSC-91): a machine-wide entry with an id in it would route
|
|
62
|
+
* every folder on the disk to one org. `auto` carries no identity at all, so an
|
|
63
|
+
* entry holding it IS safe to write once, machine-wide, into every harness — the
|
|
64
|
+
* proxy resolves the folder it was spawned in against the keystore at startup and
|
|
65
|
+
* the org still comes from the folder, exactly as before.
|
|
66
|
+
*
|
|
67
|
+
* Not a possible collision: ids are `ws_`-prefixed uuids.
|
|
68
|
+
*/
|
|
69
|
+
export const AUTO_WORKSPACE = "auto";
|
|
70
|
+
/**
|
|
71
|
+
* Which binding covers this directory? (RTSC-780)
|
|
72
|
+
*
|
|
73
|
+
* Walks UP from `dir`, matching `boundPath` at each level, so an agent spawned in
|
|
74
|
+
* a subdirectory of a bound project resolves to that project instead of to
|
|
75
|
+
* nothing. The walk is what makes `auto` usable in practice: harnesses spawn a
|
|
76
|
+
* stdio server in the project root, but not every one of them, and a human running
|
|
77
|
+
* `retasc claim` from `src/` should not be told the folder is unbound.
|
|
78
|
+
*
|
|
79
|
+
* Paths are compared resolved, so `/tmp` vs `/private/tmp` and a trailing slash
|
|
80
|
+
* don't produce a false miss.
|
|
81
|
+
*
|
|
82
|
+
* Newest wins when two ids claim the same path. A re-bind normally REUSES the
|
|
83
|
+
* folder's existing id (bind.ts), but a folder bound, unbound and bound again
|
|
84
|
+
* leaves an older entry behind, and the fresher one is the live binding.
|
|
85
|
+
*
|
|
86
|
+
* The walk STOPS at the repository root, and that bound is load-bearing rather than
|
|
87
|
+
* tidiness. An unbounded walk means binding a directory binds everything beneath it:
|
|
88
|
+
* run `retasc bind` once in `~/Development` (or `~`) and every project under it
|
|
89
|
+
* silently starts acting in that org, with no marker in those folders saying so and
|
|
90
|
+
* nothing on screen to notice. That is the cross-org leak this whole design exists to
|
|
91
|
+
* avoid (RTSC-91), reintroduced through the back door. A repository is the unit a
|
|
92
|
+
* person means by "project", so it is the unit the walk is allowed to cross: `src/lib`
|
|
93
|
+
* resolves to its repo, and a sibling repo checked out inside a bound directory
|
|
94
|
+
* resolves to nothing.
|
|
95
|
+
*
|
|
96
|
+
* Detected by the presence of `.git`, which is a FILE in a worktree and a directory in
|
|
97
|
+
* a normal clone, so `existsSync` is the right test for both.
|
|
98
|
+
*/
|
|
99
|
+
export function findBindingByPath(dir) {
|
|
100
|
+
const bindings = loadKeystore().bindings;
|
|
101
|
+
// Index by resolved boundPath once, keeping the newest per path.
|
|
102
|
+
const byPath = new Map();
|
|
103
|
+
for (const [workspaceId, entry] of Object.entries(bindings)) {
|
|
104
|
+
if (!entry?.boundPath)
|
|
105
|
+
continue;
|
|
106
|
+
const key = resolve(entry.boundPath);
|
|
107
|
+
const prev = byPath.get(key);
|
|
108
|
+
if (!prev || (entry.createdAt ?? 0) > (prev.entry.createdAt ?? 0)) {
|
|
109
|
+
byPath.set(key, { workspaceId, entry });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let cur = resolve(dir);
|
|
113
|
+
// Bounded twice: by the repository root (below), and by the filesystem root, since
|
|
114
|
+
// `dirname("/")` is `"/"` and ends the loop for a directory outside any repo.
|
|
115
|
+
for (;;) {
|
|
116
|
+
const hit = byPath.get(cur);
|
|
117
|
+
if (hit)
|
|
118
|
+
return hit;
|
|
119
|
+
// Checked AFTER the lookup so the repo root itself, the folder `bind` actually
|
|
120
|
+
// writes, is always eligible.
|
|
121
|
+
if (existsSync(join(cur, ".git")))
|
|
122
|
+
return undefined;
|
|
123
|
+
const up = dirname(cur);
|
|
124
|
+
if (up === cur)
|
|
125
|
+
return undefined;
|
|
126
|
+
cur = up;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
55
129
|
/**
|
|
56
130
|
* RTSC-98: the ONE place that resolves a workspace's key + MCP url. Shared by the
|
|
57
131
|
* proxy (env-based) and the direct commands `claim`/`tidy`/`done` (.mcp.json-based)
|
|
58
132
|
* so the two paths can never drift again. Precedence: explicit `RETASC_MCP_KEY`
|
|
59
133
|
* env → legacy inline key in the marker (env or Authorization header) → canonical
|
|
60
|
-
* secret-free marker (`RETASC_WORKSPACE` → home keystore
|
|
134
|
+
* secret-free marker (`RETASC_WORKSPACE` → home keystore, or `auto` → this
|
|
135
|
+
* process's cwd → home keystore). `mcpEntry` is a
|
|
61
136
|
* workspace `.mcp.json`'s `mcpServers.retasc` entry, or undefined when only the
|
|
62
137
|
* environment carries the binding (the spawned proxy).
|
|
63
138
|
*
|
|
@@ -97,15 +172,23 @@ export function resolveConn(opts) {
|
|
|
97
172
|
}
|
|
98
173
|
// Canonical (RTSC-92): secret-free marker → workspace id → home keystore.
|
|
99
174
|
// The entry's url is deliberately NOT consulted on this branch.
|
|
175
|
+
//
|
|
176
|
+
// RTSC-780 adds one more step in front of the id: `auto` names no workspace, so
|
|
177
|
+
// the folder we were spawned in picks it. Same trust rule either way — the key
|
|
178
|
+
// came from the keystore, so it travels only to the keystore's url. That is what
|
|
179
|
+
// lets `auto` be written into a GLOBAL harness config: the entry is identical on
|
|
180
|
+
// every machine and in every folder, and carries nothing worth stealing.
|
|
100
181
|
if (!key) {
|
|
101
182
|
const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
183
|
+
const b = wsId === AUTO_WORKSPACE
|
|
184
|
+
? findBindingByPath(opts.cwd ?? process.cwd())?.entry
|
|
185
|
+
: wsId
|
|
186
|
+
? getBinding(String(wsId))
|
|
187
|
+
: undefined;
|
|
188
|
+
if (b) {
|
|
189
|
+
key = b.key;
|
|
190
|
+
if (!url)
|
|
191
|
+
url = b.url;
|
|
109
192
|
}
|
|
110
193
|
}
|
|
111
194
|
return { key, url: url || opts.defaultUrl || "" };
|
package/dist/proxy.js
CHANGED
|
@@ -12,7 +12,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
12
12
|
import { dirname, resolve } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
|
|
15
|
-
import { resolveConn } from "./lib/keystore.js";
|
|
15
|
+
import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
|
|
16
16
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
17
17
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
18
18
|
import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
@@ -23,6 +23,21 @@ import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetch
|
|
|
23
23
|
const resolved = resolveConn({ env: process.env, defaultUrl: "https://mcp.retasc.com/mcp" });
|
|
24
24
|
let MCP_URL = resolved.url;
|
|
25
25
|
const KEY = resolved.key;
|
|
26
|
+
// RTSC-780: with the `auto` marker the SAME entry is wired into every harness, once, and
|
|
27
|
+
// is therefore present in folders that were never bound. That is no longer a broken
|
|
28
|
+
// install, it is the ordinary state of a folder you haven't run `retasc bind` in yet, so
|
|
29
|
+
// it needs an answer rather than a 401.
|
|
30
|
+
//
|
|
31
|
+
// Getting this wrong is what RTSC-781 is about. Forwarding keyless requests produces an
|
|
32
|
+
// auth failure, and the CLI's own login is global and still fine, so the agent reports
|
|
33
|
+
// "signed in, but the server is unreachable" and a human goes and checks a server that
|
|
34
|
+
// was serving other clients the whole time. The folder is the thing that is missing, so
|
|
35
|
+
// the folder is what the error has to name.
|
|
36
|
+
const UNBOUND = !KEY && (process.env.RETASC_WORKSPACE ?? "") === AUTO_WORKSPACE ? process.cwd() : null;
|
|
37
|
+
const UNBOUND_MESSAGE = `This folder (${UNBOUND ?? process.cwd()}) is not bound to a Retasc project, so there is ` +
|
|
38
|
+
`nothing to pull work from. Nothing is wrong with your sign-in or with the Retasc server. ` +
|
|
39
|
+
`Run \`retasc bind\` in this folder to say which org and project it belongs to, then restart ` +
|
|
40
|
+
`this agent so it picks the binding up.`;
|
|
26
41
|
const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
|
|
27
42
|
const leases = new Map();
|
|
28
43
|
let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
|
|
@@ -408,6 +423,18 @@ async function handleLine(line) {
|
|
|
408
423
|
// context), so every call to the tool is served here rather than forwarded.
|
|
409
424
|
if (isLocalFetchCall(msg))
|
|
410
425
|
return await handleLocalFetch(msg);
|
|
426
|
+
// RTSC-780: an unbound folder answers every tool call itself. `initialize` and
|
|
427
|
+
// `tools/list` still go remote, so the agent starts cleanly and can SEE the tools —
|
|
428
|
+
// the failure has to arrive at the moment one is used, worded as the folder problem
|
|
429
|
+
// it is, rather than as a startup crash the harness reports as "server unreachable".
|
|
430
|
+
if (UNBOUND && msg.method === "tools/call" && msg.id !== undefined) {
|
|
431
|
+
process.stdout.write(JSON.stringify({
|
|
432
|
+
jsonrpc: "2.0",
|
|
433
|
+
id: msg.id,
|
|
434
|
+
result: { content: [{ type: "text", text: UNBOUND_MESSAGE }], isError: true },
|
|
435
|
+
}) + "\n");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
411
438
|
let resp;
|
|
412
439
|
try {
|
|
413
440
|
resp = await postRemote(msg);
|
|
@@ -505,7 +532,9 @@ async function heartbeatAll() {
|
|
|
505
532
|
}
|
|
506
533
|
/** Run the stdio proxy. Started by the harness; lives as long as the session. */
|
|
507
534
|
export async function runProxy() {
|
|
508
|
-
if (
|
|
535
|
+
if (UNBOUND)
|
|
536
|
+
log(UNBOUND_MESSAGE);
|
|
537
|
+
else if (!KEY)
|
|
509
538
|
log("warning: RETASC_MCP_KEY is empty — forwarded requests will be unauthorized");
|
|
510
539
|
// Adopt a per-session key BEFORE serving traffic, so even the first claim is
|
|
511
540
|
// attributed to this session. stdin buffers in the OS pipe meanwhile.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
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": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc",
|
|
42
42
|
"dev": "tsc --watch",
|
|
43
|
-
"test": "npm run build && node --test test/*.test.mjs",
|
|
43
|
+
"test": "npm run build && node --test --import ./test/_test-home.mjs test/*.test.mjs",
|
|
44
44
|
"prepublishOnly": "npm run build"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|