@retasc/cli 1.48.0 → 1.49.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 +59 -0
- package/dist/auth.js +117 -0
- package/dist/commands/bind.js +119 -3
- package/dist/commands/doctor.js +114 -14
- package/dist/config.js +42 -0
- package/dist/lib/binding.js +1 -1
- package/dist/lib/launcher.js +24 -7
- package/dist/lib/outcome.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,65 @@ 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.49.0 (2026-09-09)
|
|
10
|
+
|
|
11
|
+
- **RTSC-879** — a committed `.mcp.json` now names `npx -y @retasc/cli@<version> mcp-proxy`
|
|
12
|
+
instead of a bare `retasc`, so it starts on a machine that is not the one that wrote it.
|
|
13
|
+
`portableLauncher` used to pass an on-PATH resolution straight through on the belief that
|
|
14
|
+
a bare binary "was at least portable"; it is portable only across machines that happen to
|
|
15
|
+
have a global install. A container has none. Measured in a Claude Code cloud session on a
|
|
16
|
+
fresh clone: the MCP server died at `ENOENT: Executable not found in $PATH: retasc` before
|
|
17
|
+
it could do anything else. Startup on the shared marker now pays npx resolution (about 6s
|
|
18
|
+
cold, ~1s off the cache); user-level configs, which never travel, keep the direct binary.
|
|
19
|
+
- **RTSC-879** — `retasc bind --json` can now sign in from a container. An agent-driven
|
|
20
|
+
bind takes the DEVICE grant instead of the browser one, in two steps: the first call
|
|
21
|
+
prints an approve URL and an eight-character code and **exits**, the human approves on
|
|
22
|
+
any device, and running the same command again resumes the same grant and finishes.
|
|
23
|
+
Previously an agent got the browser door, which redirects to `127.0.0.1` on the machine
|
|
24
|
+
running the CLI — unreachable when the human is somewhere else, which in a cloud
|
|
25
|
+
container they always are. New continuable outcome state `SIGN_IN_PENDING` so an agent
|
|
26
|
+
can tell "waiting on a click" from "nothing has started" and does not issue a second
|
|
27
|
+
code that invalidates the one its human is reading. CI and `RETASC_NO_BROWSER` still
|
|
28
|
+
refuse immediately: neither has anyone to approve anything.
|
|
29
|
+
- **RTSC-879** — `retasc doctor` no longer installs a package to check a marker. For an
|
|
30
|
+
npx marker it probes `npx --version` rather than executing the pinned package spec,
|
|
31
|
+
which was a registry fetch on every run and reported "your agent CANNOT start Retasc"
|
|
32
|
+
for a healthy marker whenever npm was unreachable. Its remedy line is corrected too: a
|
|
33
|
+
global install can no longer change what `bind` writes into a shared marker.
|
|
34
|
+
- **RTSC-879** — the repo's own committed `.mcp.json`, and the marker blocks published in
|
|
35
|
+
`web/auth.md` and the agent-skill, now show the npx form. They were the bare binary,
|
|
36
|
+
which is the exact thing this release stops emitting. Those hand-written blocks say
|
|
37
|
+
`@latest` rather than a pinned version on purpose: `release:cli` publishes from `main`
|
|
38
|
+
AFTER a merge, so a committed marker pinning the version being released would 404 for
|
|
39
|
+
every fresh clone in the window between the two — trading this release's ENOENT for an
|
|
40
|
+
E404. `bind` still writes the pinned form, which is correct because it only ever names
|
|
41
|
+
a version that already exists.
|
|
42
|
+
- **[no-issue]** — test fix: the setup-hook test pinned `RETASC_HOME` to a temp dir. It
|
|
43
|
+
fell through to the real home directory, so it passed on a clean CI runner and failed
|
|
44
|
+
on any machine that had actually run `retasc setup`.
|
|
45
|
+
|
|
46
|
+
## 1.48.1 (2026-09-08)
|
|
47
|
+
|
|
48
|
+
- **RTSC-878** — `retasc doctor` no longer condemns the entry `retasc setup` writes. Its
|
|
49
|
+
machine-global check dated from RTSC-91, when every top-level `mcpServers.retasc` carried
|
|
50
|
+
a key or a workspace id and so routed every unbound folder to one org; presence alone was
|
|
51
|
+
a correct test for that. RTSC-780 then made one global entry mandatory, the keyless `auto`
|
|
52
|
+
marker, which carries no identity at all, and the check was never taught the difference.
|
|
53
|
+
Doctor therefore printed `✗ GLOBAL Retasc server registered` at the marker and offered
|
|
54
|
+
`claude mcp remove -s user retasc` as the fix, which strips Retasc from every folder
|
|
55
|
+
without a binding of its own. It cost a customer a day.
|
|
56
|
+
- The check now reads the entry's identity: `RETASC_WORKSPACE=auto` with no key resolved is
|
|
57
|
+
reported as correct and named, so someone who just ran `setup` can see that what it wrote
|
|
58
|
+
is what belongs there. Every other shape stays illegal, including a keyless entry that
|
|
59
|
+
names a real `ws_` workspace, which still routes every unbound folder to one binding.
|
|
60
|
+
- The exemption is not granted on the sentinel alone. `parseServerEntry` reads
|
|
61
|
+
`RETASC_WORKSPACE` before it looks for a key in an `Authorization` header or in
|
|
62
|
+
`RETASC_MCP_KEY`, so an entry carrying both the `auto` sentinel and a real credential
|
|
63
|
+
parses as an innocent keyless marker; judging the parse alone would have exempted a live
|
|
64
|
+
machine-global registration pointing every unbound folder at that key's org. The raw
|
|
65
|
+
entry is examined too, and anything carrying credential material or a `url` of its own
|
|
66
|
+
stays illegal whatever its `env` claims.
|
|
67
|
+
|
|
9
68
|
## 1.48.0 (2026-09-08)
|
|
10
69
|
|
|
11
70
|
- **RTSC-859** — `retasc key mint --hosted`: a key now says at mint what it is for.
|
package/dist/auth.js
CHANGED
|
@@ -287,6 +287,123 @@ async function browserLogin(deploymentUrl, dashUrl, onUrl) {
|
|
|
287
287
|
// what actually worked.
|
|
288
288
|
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
289
289
|
}
|
|
290
|
+
/** Ask the provider for a code. Returns immediately; nobody waits here. */
|
|
291
|
+
export async function deviceStart(provider, deploymentUrl) {
|
|
292
|
+
if (provider === "google") {
|
|
293
|
+
const convex = new ConvexHttpClient(deploymentUrl);
|
|
294
|
+
const start = (await convex.action(googleDeviceStart, {}));
|
|
295
|
+
return {
|
|
296
|
+
provider,
|
|
297
|
+
deploymentUrl,
|
|
298
|
+
deviceCode: start.deviceCode,
|
|
299
|
+
userCode: start.userCode,
|
|
300
|
+
verificationUri: start.verificationUrl,
|
|
301
|
+
...(start.verificationUrlComplete
|
|
302
|
+
? { verificationUriComplete: start.verificationUrlComplete }
|
|
303
|
+
: {}),
|
|
304
|
+
intervalMs: (start.intervalSeconds || 5) * 1000,
|
|
305
|
+
expiresAt: Date.now() + start.expiresInSeconds * 1000,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (!GITHUB_CLIENT_ID) {
|
|
309
|
+
throw new Error("GitHub client id not configured. Set RETASC_GITHUB_CLIENT_ID (the OAuth App's public Client ID).");
|
|
310
|
+
}
|
|
311
|
+
const start = await postForm(DEVICE_CODE_URL, {
|
|
312
|
+
client_id: GITHUB_CLIENT_ID,
|
|
313
|
+
scope: "read:user user:email",
|
|
314
|
+
});
|
|
315
|
+
if (!start.device_code) {
|
|
316
|
+
// Same rule as `githubDeviceToken`: quote what GitHub WROTE for a person, never the
|
|
317
|
+
// raw payload.
|
|
318
|
+
const said = start.error_description ?? start.error;
|
|
319
|
+
throw new Error(`GitHub didn't hand back a sign-in code${said ? `: ${clean(said)}` : "."} ` +
|
|
320
|
+
`Check your connection and try again.`);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
provider,
|
|
324
|
+
deploymentUrl,
|
|
325
|
+
deviceCode: start.device_code,
|
|
326
|
+
userCode: start.user_code,
|
|
327
|
+
verificationUri: start.verification_uri,
|
|
328
|
+
...(start.verification_uri_complete
|
|
329
|
+
? { verificationUriComplete: start.verification_uri_complete }
|
|
330
|
+
: {}),
|
|
331
|
+
intervalMs: (start.interval || 5) * 1000,
|
|
332
|
+
expiresAt: Date.now() + start.expires_in * 1000,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Poll an already-started grant for at most `budgetMs`, and sign in if it completed.
|
|
337
|
+
*
|
|
338
|
+
* BOUNDED ON PURPOSE. The caller is an agent, and an agent that blocks for the grant's
|
|
339
|
+
* full fifteen minutes is the very failure this split exists to avoid. `false` means
|
|
340
|
+
* "not yet, ask again" — the human has not clicked — and is not an error. Anything
|
|
341
|
+
* genuinely terminal (denied, expired, a misconfigured client) throws, because those
|
|
342
|
+
* cannot be fixed by asking again and an agent must be told to stop rather than loop.
|
|
343
|
+
*/
|
|
344
|
+
export async function deviceFinish(pending, deploymentUrl, budgetMs) {
|
|
345
|
+
const convex = new ConvexHttpClient(deploymentUrl);
|
|
346
|
+
let intervalMs = pending.intervalMs || 5000;
|
|
347
|
+
const stopAt = Math.min(Date.now() + budgetMs, pending.expiresAt);
|
|
348
|
+
let upstream;
|
|
349
|
+
while (Date.now() < stopAt) {
|
|
350
|
+
// Never poll SOONER than the provider asked. Clamping the sleep to the remaining
|
|
351
|
+
// budget would do exactly that on the last iteration — and after a `slow_down` that
|
|
352
|
+
// interval is the provider telling us to back off. Stop instead; the caller
|
|
353
|
+
// re-enters on its next invocation anyway.
|
|
354
|
+
const wait = Math.min(intervalMs, Math.max(0, stopAt - Date.now()));
|
|
355
|
+
if (wait < intervalMs)
|
|
356
|
+
break;
|
|
357
|
+
await sleep(wait);
|
|
358
|
+
if (Date.now() > pending.expiresAt)
|
|
359
|
+
break;
|
|
360
|
+
if (pending.provider === "google") {
|
|
361
|
+
const r = (await convex.action(googleDevicePoll, { deviceCode: pending.deviceCode }));
|
|
362
|
+
if (r.status === "ok") {
|
|
363
|
+
upstream = r.googleToken;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (r.status === "slowDown")
|
|
367
|
+
intervalMs += 5000;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const r = await postForm(TOKEN_URL, {
|
|
371
|
+
client_id: GITHUB_CLIENT_ID,
|
|
372
|
+
device_code: pending.deviceCode,
|
|
373
|
+
grant_type: GRANT,
|
|
374
|
+
});
|
|
375
|
+
if (r.access_token) {
|
|
376
|
+
upstream = r.access_token;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
if (r.error === "authorization_pending")
|
|
380
|
+
continue;
|
|
381
|
+
if (r.error === "slow_down") {
|
|
382
|
+
intervalMs += 5000;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
throw new Error(`Authorization failed: ${clean(String(r.error_description ?? r.error))}`);
|
|
386
|
+
}
|
|
387
|
+
if (!upstream) {
|
|
388
|
+
if (Date.now() >= pending.expiresAt) {
|
|
389
|
+
throw new Error("The sign-in code expired before it was approved. Start again.");
|
|
390
|
+
}
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
const res = await convex.action(signIn, {
|
|
394
|
+
provider: pending.provider === "google" ? "google-device" : "github-device",
|
|
395
|
+
params: pending.provider === "google" ? { googleToken: upstream } : { githubToken: upstream },
|
|
396
|
+
});
|
|
397
|
+
const tokens = res?.tokens;
|
|
398
|
+
if (!tokens?.token)
|
|
399
|
+
throw new Error("Sign-in did not return a session token.");
|
|
400
|
+
patchConfig({
|
|
401
|
+
token: tokens.token,
|
|
402
|
+
refreshToken: tokens.refreshToken,
|
|
403
|
+
loginProvider: pending.provider,
|
|
404
|
+
});
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
290
407
|
/**
|
|
291
408
|
* Run a device-flow login and persist the resulting Convex Auth session to
|
|
292
409
|
* ~/.retasc/config.json. Prints the user code + verification URL.
|
package/dist/commands/bind.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { api, cliError } from "../api.js";
|
|
3
|
-
import { deviceLogin } from "../auth.js";
|
|
3
|
+
import { deviceLogin, deviceStart, deviceFinish } from "../auth.js";
|
|
4
4
|
import { loadConfig, patchConfig } from "../config.js";
|
|
5
5
|
import { installMarker, printMarkerBlock } from "./mcp.js";
|
|
6
6
|
import { runSetup } from "./setup.js";
|
|
@@ -730,6 +730,30 @@ export const NEXT_STEP = "Start your agent in this folder, or restart it if it's
|
|
|
730
730
|
* because "run this, but run the other one first" is not a single instruction. So the
|
|
731
731
|
* sign-in is explicit, announced, and only when a human is present to authorize it.
|
|
732
732
|
*/
|
|
733
|
+
/**
|
|
734
|
+
* RTSC-879 — thrown when a device grant has been started and the human has not approved
|
|
735
|
+
* it yet. NOT a failure: `bindAction` turns it into a CONTINUABLE outcome, because the
|
|
736
|
+
* only thing missing is a click that has not happened yet.
|
|
737
|
+
*/
|
|
738
|
+
/**
|
|
739
|
+
* How long a resuming `bind --json` waits for the click before handing control back.
|
|
740
|
+
*
|
|
741
|
+
* Ninety seconds is chosen against the agent's loop, not the human's: long enough that
|
|
742
|
+
* a human who clicks while the agent is still talking is picked up on the same call,
|
|
743
|
+
* short enough that a run never looks hung. Being wrong is cheap in one direction only
|
|
744
|
+
* — too short just means one more `bind --json`, too long means an agent that looks
|
|
745
|
+
* dead — so it errs short.
|
|
746
|
+
*/
|
|
747
|
+
const RESUME_POLL_MS = 90_000;
|
|
748
|
+
export class SignInPending extends Error {
|
|
749
|
+
url;
|
|
750
|
+
code;
|
|
751
|
+
constructor(url, code) {
|
|
752
|
+
super("sign-in pending: waiting for approval");
|
|
753
|
+
this.url = url;
|
|
754
|
+
this.code = code;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
733
757
|
export async function ensureSignedIn(agent) {
|
|
734
758
|
if (loadConfig().token)
|
|
735
759
|
return;
|
|
@@ -750,11 +774,81 @@ export async function ensureSignedIn(agent) {
|
|
|
750
774
|
// would itself fail silently.
|
|
751
775
|
cliError("UNAUTHENTICATED", `Not signed in. Run \`${selfCommand(VERSION)} login\` first (no TTY here for the device flow).`, versionStamp(VERSION).trim());
|
|
752
776
|
}
|
|
777
|
+
// RTSC-879 — an AGENT always takes the device door, and takes it in two steps.
|
|
778
|
+
//
|
|
779
|
+
// Not the browser door, which is what an agent-driven run used to get. That door sends
|
|
780
|
+
// the human to a URL redirecting to `127.0.0.1` on THIS machine, so it only works when
|
|
781
|
+
// the browser and the CLI share a host. An agent's human may be anywhere, and in a
|
|
782
|
+
// cloud container they always are, so the click lands on a port nobody can reach and
|
|
783
|
+
// the CLI waits out its timeout. `canOpenBrowser` does not catch that: it looks for
|
|
784
|
+
// SSH, CI and an explicit opt-out, and a cloud container sets none of them, so it
|
|
785
|
+
// reports a browser that is not there. A device grant does not care where the human
|
|
786
|
+
// is, which is why it is the door for every agent rather than a fallback for some.
|
|
787
|
+
//
|
|
788
|
+
// Two steps because one does not work: an agent running a blocking command sees no
|
|
789
|
+
// stdout until the process exits, so a code printed above a fifteen-minute wait
|
|
790
|
+
// reaches nobody. START prints and exits; the next `bind --json` FINISHes.
|
|
791
|
+
if (agent) {
|
|
792
|
+
// Two doors stay shut, and only two. A device grant works wherever the human is, so
|
|
793
|
+
// SSH and a container are no longer dead ends — but neither of these is about WHERE
|
|
794
|
+
// the human is:
|
|
795
|
+
// • CI has nobody watching at all. A code emitted there is read by no one and the
|
|
796
|
+
// run would ask for it again forever.
|
|
797
|
+
// • RETASC_NO_BROWSER is a person saying "do not put me through browser auth". A
|
|
798
|
+
// device grant is still browser auth, just on a different device, so honouring
|
|
799
|
+
// the switch means honouring it here too.
|
|
800
|
+
if (process.env.CI || process.env.RETASC_NO_BROWSER) {
|
|
801
|
+
throw new Error("no browser door here: " +
|
|
802
|
+
(process.env.CI
|
|
803
|
+
? "this is CI, and a sign-in code needs a human to approve it. Mint a key on a machine with a browser and set RETASC_MCP_KEY instead."
|
|
804
|
+
: "RETASC_NO_BROWSER is set. Unset it, or sign in on another machine and bind there."));
|
|
805
|
+
}
|
|
806
|
+
const cfg = loadConfig();
|
|
807
|
+
// A grant started against a DIFFERENT backend is not resumable here: Google's half
|
|
808
|
+
// lives in Convex, so polling another deployment asks a server about a code it never
|
|
809
|
+
// issued. Treat it as absent and start a fresh one.
|
|
810
|
+
const stored = cfg.pendingDevice && (cfg.pendingDevice.deploymentUrl ?? cfg.deploymentUrl) === cfg.deploymentUrl
|
|
811
|
+
? cfg.pendingDevice
|
|
812
|
+
: undefined;
|
|
813
|
+
// Resume an approval already in flight. Bounded: `deviceFinish` returns false rather
|
|
814
|
+
// than holding the process open, so every invocation ends promptly whatever the
|
|
815
|
+
// human is doing.
|
|
816
|
+
if (stored && Date.now() < stored.expiresAt) {
|
|
817
|
+
let done;
|
|
818
|
+
try {
|
|
819
|
+
done = await deviceFinish(stored, cfg.deploymentUrl, RESUME_POLL_MS);
|
|
820
|
+
}
|
|
821
|
+
catch (e) {
|
|
822
|
+
// TERMINAL, so the grant must go. `deviceFinish` throws only on things retrying
|
|
823
|
+
// cannot fix — the human declined, the code expired, the client is misconfigured
|
|
824
|
+
// — and a dead grant left in the config is re-loaded by the branch above on every
|
|
825
|
+
// later run, which would rethrow the same refusal forever and leave no way to
|
|
826
|
+
// start a new one short of editing the file by hand.
|
|
827
|
+
patchConfig({ pendingDevice: undefined });
|
|
828
|
+
throw e;
|
|
829
|
+
}
|
|
830
|
+
if (done) {
|
|
831
|
+
patchConfig({ pendingDevice: undefined });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
throw new SignInPending(stored.verificationUriComplete || stored.verificationUri, stored.userCode);
|
|
835
|
+
}
|
|
836
|
+
// No grant, or the last one died of old age, or it belongs to another deployment.
|
|
837
|
+
// Clear before starting rather than relying on the write below to overwrite it: if
|
|
838
|
+
// `deviceStart` throws, a dead grant left behind would be re-read on the next run.
|
|
839
|
+
if (cfg.pendingDevice)
|
|
840
|
+
patchConfig({ pendingDevice: undefined });
|
|
841
|
+
const started = await deviceStart(cfg.loginProvider ?? "github", cfg.deploymentUrl);
|
|
842
|
+
patchConfig({ pendingDevice: started });
|
|
843
|
+
const url = started.verificationUriComplete || started.verificationUri;
|
|
844
|
+
agent.onDeviceCode?.({ url, code: started.userCode });
|
|
845
|
+
throw new SignInPending(url, started.userCode);
|
|
846
|
+
}
|
|
753
847
|
// RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
|
|
754
848
|
// announcing "GitHub" before the question would be wrong for the invited
|
|
755
849
|
// teammate who signed up through Google — the exact person `join` is for.
|
|
756
850
|
console.error("Not signed in yet — let's do that first.");
|
|
757
|
-
await deviceLogin(undefined
|
|
851
|
+
await deviceLogin(undefined);
|
|
758
852
|
}
|
|
759
853
|
export async function bindAction(opts) {
|
|
760
854
|
// RTSC-713 — `--json` is the signal that a machine is driving, and it turns three
|
|
@@ -763,7 +857,14 @@ export async function bindAction(opts) {
|
|
|
763
857
|
// independently useful: structured output nobody can act on, or a door that leads to a
|
|
764
858
|
// thrown Error, is each worse than neither.
|
|
765
859
|
const agent = opts.json
|
|
766
|
-
? {
|
|
860
|
+
? {
|
|
861
|
+
onUrl: (url) => emit({ event: "approve_url", url }),
|
|
862
|
+
// RTSC-879 — the device grant emits the SAME event, plus the code. Without this
|
|
863
|
+
// the URL reached an agent only inside the outcome's free-text `next`, so a
|
|
864
|
+
// client parsing NDJSON (which is what `--json` is for) lost the one field it
|
|
865
|
+
// was watching for.
|
|
866
|
+
onDeviceCode: (d) => emit({ event: "approve_url", url: d.url, code: d.code }),
|
|
867
|
+
}
|
|
767
868
|
: undefined;
|
|
768
869
|
const folder = process.cwd();
|
|
769
870
|
// RTSC-722 — the escape option's value is a sentinel, and this is where it stops being
|
|
@@ -808,6 +909,21 @@ export async function bindAction(opts) {
|
|
|
808
909
|
catch (e) {
|
|
809
910
|
if (!agent)
|
|
810
911
|
throw e;
|
|
912
|
+
// RTSC-879 — started, not failed. The only missing thing is a click, so this is
|
|
913
|
+
// CONTINUABLE: exit 0, tell the agent what to show its human, and let the next
|
|
914
|
+
// `bind --json` resume the same grant. Reported as a failure it would read as
|
|
915
|
+
// "this cannot work here", which is exactly backwards.
|
|
916
|
+
if (e instanceof SignInPending) {
|
|
917
|
+
pause({
|
|
918
|
+
state: "SIGN_IN_PENDING",
|
|
919
|
+
next: `Sign-in has started and is waiting for your human. Show them BOTH of these, ` +
|
|
920
|
+
`then run this same command again once they say they have approved it:\n` +
|
|
921
|
+
` Open: ${e.url}\n Enter code: ${e.code}\n` +
|
|
922
|
+
`They can open it on any device — a phone, another laptop — it does not have ` +
|
|
923
|
+
`to be this machine.`,
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
811
927
|
// Sign-in was ATTEMPTED and did not complete. Distinct from arriving with no session,
|
|
812
928
|
// which is continuable — collapsing the two is what made the old refusal stop agents
|
|
813
929
|
// that had actually succeeded.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
|
|
3
|
-
import { getBinding } from "../lib/keystore.js";
|
|
3
|
+
import { AUTO_WORKSPACE, getBinding } from "../lib/keystore.js";
|
|
4
4
|
import { gitCommonDir } from "../lib/gitRepo.js";
|
|
5
5
|
import { detectHarnesses } from "../lib/harness.js";
|
|
6
6
|
import { runsOk } from "../lib/launcher.js";
|
|
@@ -16,8 +16,9 @@ import { clean } from "../lib/text.js";
|
|
|
16
16
|
// binding, which teaches people to distrust everything else it says. When both
|
|
17
17
|
// exist, we report the claude-local one (what Claude Code actually runs;
|
|
18
18
|
// local > project) and warn if a shadowed folder marker disagrees. The
|
|
19
|
-
// illegal-global check
|
|
20
|
-
// can't prove (unreadable config
|
|
19
|
+
// illegal-global check reads the entry's IDENTITY, not merely its presence
|
|
20
|
+
// (RTSC-878), and never prints an all-clear it can't prove (unreadable config
|
|
21
|
+
// ≠ no global server).
|
|
21
22
|
const ok = (m) => console.log(` ✓ ${m}`);
|
|
22
23
|
const warn = (m) => console.log(` ! ${m}`);
|
|
23
24
|
const bad = (m) => console.log(` ✗ ${m}`);
|
|
@@ -43,11 +44,89 @@ export function launcherVerdict(local, probe = runsOk) {
|
|
|
43
44
|
// The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
|
|
44
45
|
const args = local.args ?? [];
|
|
45
46
|
const probeArgs = args[args.length - 1] === "mcp-proxy" ? args.slice(0, -1) : args;
|
|
47
|
+
// RTSC-879 — an npx marker is probed by asking whether NPX runs, never by running the
|
|
48
|
+
// package spec. `runsOk` executes what it is handed, so probing `npx -y @retasc/cli@X`
|
|
49
|
+
// means a registry fetch and an install on every `doctor` — slow when it works, and a
|
|
50
|
+
// false "your agent CANNOT start Retasc" the moment the machine is offline or npm is
|
|
51
|
+
// unreachable. What this check is actually asking is whether the LAUNCHER exists, and
|
|
52
|
+
// for the npx form that question is answered by `npx --version`.
|
|
53
|
+
//
|
|
54
|
+
// WHAT THIS STOPS CHECKING, said out loud: it proves the launcher exists and nothing
|
|
55
|
+
// about the pinned spec. A version that is not on npm, a registry behind a proxy, or a
|
|
56
|
+
// corrupt npx cache all read as healthy here while the server dies at startup. That is
|
|
57
|
+
// the deliberate trade — a false "CANNOT start" every time someone is offline is worse
|
|
58
|
+
// than a rare false "can" — but since 1.49.0 every shared marker is this form, so it
|
|
59
|
+
// is a blind spot on the common path, not an edge one.
|
|
60
|
+
if (local.command === "npx") {
|
|
61
|
+
return {
|
|
62
|
+
startable: Boolean(probe("npx", ["--version"])),
|
|
63
|
+
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
46
66
|
return {
|
|
47
67
|
startable: Boolean(probe(local.command, probeArgs)),
|
|
48
68
|
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
49
69
|
};
|
|
50
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* RTSC-878: is the machine-global entry a real registration, or the marker `setup` writes?
|
|
73
|
+
*
|
|
74
|
+
* The old rule was presence alone, and it was correct when it was written (RTSC-91): every
|
|
75
|
+
* top-level `mcpServers.retasc` then carried a key or a workspace id, so any of them routed
|
|
76
|
+
* every unbound folder on the disk to one org. RTSC-780 changed that world by making ONE
|
|
77
|
+
* global entry mandatory, the keyless `auto` marker, which carries no identity at all: the
|
|
78
|
+
* proxy resolves the folder it was spawned in and the org still comes from the folder.
|
|
79
|
+
*
|
|
80
|
+
* Doctor kept the old rule and so told people to delete what `retasc setup` had just
|
|
81
|
+
* installed. Following that advice strips Retasc from every folder without a binding of its
|
|
82
|
+
* own, and it happened to a real customer before this was fixed.
|
|
83
|
+
*
|
|
84
|
+
* The test is the marker's OWN sentinel rather than a shape heuristic: `RETASC_WORKSPACE`
|
|
85
|
+
* is `auto` and no key resolved from it. Workspace ids are `ws_`-prefixed uuids
|
|
86
|
+
* (`keystore.ts`), so `auto` can never be one, and an entry naming a real workspace stays
|
|
87
|
+
* illegal exactly as before.
|
|
88
|
+
*
|
|
89
|
+
* The sentinel alone is NOT enough, and this is the trap the exemption has to survive.
|
|
90
|
+
* `parseServerEntry` reads `RETASC_WORKSPACE` BEFORE it looks for a key in an
|
|
91
|
+
* `Authorization` header or in `RETASC_MCP_KEY`, so an entry that carries BOTH the `auto`
|
|
92
|
+
* sentinel and a real credential parses as an innocent keyless marker. That shape is a
|
|
93
|
+
* live machine-global registration pointing every unbound folder at whatever org its key
|
|
94
|
+
* names, which is the exact fault this check exists to catch, and it is not theoretical:
|
|
95
|
+
* `lib/harness.ts` records an agent hand-writing a machine-global entry with an inline key.
|
|
96
|
+
* So the raw entry is examined too, and anything carrying credential material or its own
|
|
97
|
+
* endpoint is illegal no matter what its `env` claims.
|
|
98
|
+
*/
|
|
99
|
+
export function globalVerdict(global) {
|
|
100
|
+
if (global.status === "none")
|
|
101
|
+
return "clear";
|
|
102
|
+
if (global.status === "unreadable")
|
|
103
|
+
return "unreadable";
|
|
104
|
+
const b = global.binding;
|
|
105
|
+
if (b && b.workspaceId === AUTO_WORKSPACE && !b.key && isBareMarker(global.raw))
|
|
106
|
+
return "auto";
|
|
107
|
+
return "illegal";
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Does the entry as written carry nothing but the `auto` sentinel?
|
|
111
|
+
*
|
|
112
|
+
* Deliberately a DENYLIST of credential-bearing and endpoint-bearing fields rather than an
|
|
113
|
+
* exact-shape match: `setup` may add a field to the marker later (it already grew `env`
|
|
114
|
+
* once), and an exemption that broke on every such addition would be reverted rather than
|
|
115
|
+
* updated. What must never be exempt is an entry that can reach a server on its own terms:
|
|
116
|
+
* a key in `env`, a key in a header, or a `url` of its own.
|
|
117
|
+
*/
|
|
118
|
+
function isBareMarker(raw) {
|
|
119
|
+
if (!raw || typeof raw !== "object")
|
|
120
|
+
return false;
|
|
121
|
+
const e = raw;
|
|
122
|
+
if (e.url !== undefined)
|
|
123
|
+
return false;
|
|
124
|
+
if (e.headers !== undefined)
|
|
125
|
+
return false;
|
|
126
|
+
if (e.env && typeof e.env === "object" && e.env.RETASC_MCP_KEY !== undefined)
|
|
127
|
+
return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
51
130
|
/**
|
|
52
131
|
* RTSC-498: macOS is the only platform this CLI is tested on. That was true and written
|
|
53
132
|
* down nowhere, which left someone on Linux or Windows unable to tell their own mistake
|
|
@@ -89,10 +168,19 @@ function checkLauncher(local) {
|
|
|
89
168
|
ok(`your agent can start Retasc (${v.shown}).`);
|
|
90
169
|
return;
|
|
91
170
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
171
|
+
// RTSC-879 — the remedy differs by form, because a global install no longer changes
|
|
172
|
+
// what `bind` writes into a SHARED marker: `portableLauncher` emits the npx form for
|
|
173
|
+
// every resolution now, so "install it globally and re-bind" would rewrite the
|
|
174
|
+
// identical entry and fix nothing.
|
|
175
|
+
bad(local?.command === "npx"
|
|
176
|
+
? `your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
|
|
177
|
+
` and npx itself did not run here. That is a Node/npm problem, not a Retasc one.\n` +
|
|
178
|
+
` Fix: make sure \`npx --version\` works, then try again. If this machine is\n` +
|
|
179
|
+
` offline or npm is unreachable, that is the cause and nothing is broken.`
|
|
180
|
+
: `your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
|
|
181
|
+
` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
|
|
182
|
+
` Fix: re-run \`retasc bind\` here — it now writes a portable npx entry that\n` +
|
|
183
|
+
` starts on any machine with Node, including one with no global install.`);
|
|
96
184
|
}
|
|
97
185
|
export async function doctorAction() {
|
|
98
186
|
const cfg = loadConfig();
|
|
@@ -192,22 +280,34 @@ export async function doctorAction() {
|
|
|
192
280
|
}
|
|
193
281
|
// 2) The safety check: any illegal machine-global Retasc server?
|
|
194
282
|
const global = readGlobalBinding();
|
|
195
|
-
|
|
283
|
+
// Pulled out once: the verdict below is computed from the same entry, and reading it
|
|
284
|
+
// through the union again inside a branch loses the `present` narrowing.
|
|
285
|
+
const entry = global.status === "present" ? global.binding : undefined;
|
|
286
|
+
const verdict = globalVerdict(global);
|
|
287
|
+
if (verdict === "clear") {
|
|
196
288
|
ok("no illegal global Retasc server.");
|
|
197
289
|
}
|
|
198
|
-
else if (
|
|
290
|
+
else if (verdict === "auto") {
|
|
291
|
+
// RTSC-878: the entry `retasc setup` writes, and the one state doctor used to
|
|
292
|
+
// condemn. Named rather than passed over in silence: someone who just ran `setup`
|
|
293
|
+
// should see that the thing it wrote is the thing that is supposed to be there.
|
|
294
|
+
ok("machine-wide Retasc entry is the keyless `auto` marker `retasc setup` writes;\n" +
|
|
295
|
+
" it names no org, so every folder still resolves its own binding.");
|
|
296
|
+
}
|
|
297
|
+
else if (verdict === "unreadable") {
|
|
199
298
|
warn("could not parse Claude Code's config — cannot verify there is no global Retasc server\n" +
|
|
200
299
|
" (a local-scope binding for this folder would be invisible too).");
|
|
201
300
|
}
|
|
202
301
|
else {
|
|
203
302
|
// Name the org it points at when we can: "leaks across orgs" is abstract,
|
|
204
|
-
// "every unbound folder gets org X" is what makes someone act.
|
|
205
|
-
//
|
|
206
|
-
//
|
|
303
|
+
// "every unbound folder gets org X" is what makes someone act. A keyless entry
|
|
304
|
+
// that names a WORKSPACE is still illegal (it routes every unbound folder to that
|
|
305
|
+
// one binding); only the identity-free `auto` marker above is not. If the network
|
|
306
|
+
// already failed above, don't burn another timeout.
|
|
207
307
|
let points = "";
|
|
208
|
-
if (
|
|
308
|
+
if (entry?.key && !networkDown) {
|
|
209
309
|
try {
|
|
210
|
-
const b = await resolveBinding(
|
|
310
|
+
const b = await resolveBinding(entry.url || cfg.mcpUrl, entry.key);
|
|
211
311
|
points = ` It points at org "${clean(b.org.name)}".`;
|
|
212
312
|
}
|
|
213
313
|
catch {
|
package/dist/config.js
CHANGED
|
@@ -74,6 +74,48 @@ export function loadConfig() {
|
|
|
74
74
|
updateNoticeAt: typeof stored.updateNoticeAt === "number" && Number.isFinite(stored.updateNoticeAt)
|
|
75
75
|
? stored.updateNoticeAt
|
|
76
76
|
: undefined,
|
|
77
|
+
// RTSC-879 — and it MUST be here. `loadConfig` rebuilds the object field by field, so
|
|
78
|
+
// a field absent from this literal is invisible to every reader no matter what is on
|
|
79
|
+
// disk. Left out, `bind --json` never saw the grant it had just written: it minted a
|
|
80
|
+
// fresh code on every call, invalidating the one the human was reading, and exited 0
|
|
81
|
+
// forever. The whole suite stayed green because nothing round-tripped the grant
|
|
82
|
+
// through the config, which is why the test below does exactly that.
|
|
83
|
+
//
|
|
84
|
+
// Validated, same rule as the fields above: the file is hand-editable, and this one
|
|
85
|
+
// is fed straight to a polling loop. An unrecognised provider must not fall through
|
|
86
|
+
// to GitHub's branch by default, and a non-finite `expiresAt` would make the grant
|
|
87
|
+
// either immortal or already dead.
|
|
88
|
+
pendingDevice: readPendingDevice(stored.pendingDevice),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** A stored device grant, or `undefined` if it is anything other than a complete one. */
|
|
92
|
+
function readPendingDevice(v) {
|
|
93
|
+
if (!v || typeof v !== "object")
|
|
94
|
+
return undefined;
|
|
95
|
+
const str = (x) => (typeof x === "string" && x.length > 0 ? x : undefined);
|
|
96
|
+
const provider = v.provider === "github" || v.provider === "google" ? v.provider : undefined;
|
|
97
|
+
const deviceCode = str(v.deviceCode);
|
|
98
|
+
const userCode = str(v.userCode);
|
|
99
|
+
const verificationUri = str(v.verificationUri);
|
|
100
|
+
const expiresAt = typeof v.expiresAt === "number" && Number.isFinite(v.expiresAt) ? v.expiresAt : undefined;
|
|
101
|
+
if (!provider || !deviceCode || !userCode || !verificationUri || expiresAt === undefined) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// A negative or absent interval would busy-poll a provider that asked us not to.
|
|
105
|
+
const intervalMs = typeof v.intervalMs === "number" && Number.isFinite(v.intervalMs) && v.intervalMs > 0
|
|
106
|
+
? v.intervalMs
|
|
107
|
+
: 5000;
|
|
108
|
+
return {
|
|
109
|
+
provider,
|
|
110
|
+
deviceCode,
|
|
111
|
+
userCode,
|
|
112
|
+
verificationUri,
|
|
113
|
+
...(str(v.verificationUriComplete)
|
|
114
|
+
? { verificationUriComplete: v.verificationUriComplete }
|
|
115
|
+
: {}),
|
|
116
|
+
intervalMs,
|
|
117
|
+
expiresAt,
|
|
118
|
+
...(str(v.deploymentUrl) ? { deploymentUrl: v.deploymentUrl } : {}),
|
|
77
119
|
};
|
|
78
120
|
}
|
|
79
121
|
/** Move a corrupt config aside to a unique sibling so a human can recover any
|
package/dist/lib/binding.js
CHANGED
|
@@ -213,7 +213,7 @@ export function readGlobalBinding() {
|
|
|
213
213
|
const raw = doc?.mcpServers?.retasc;
|
|
214
214
|
if (!raw)
|
|
215
215
|
return { status: "none" };
|
|
216
|
-
return { status: "present", binding: parseServerEntry(raw, "global") };
|
|
216
|
+
return { status: "present", binding: parseServerEntry(raw, "global"), raw };
|
|
217
217
|
}
|
|
218
218
|
/** Two per-folder bindings are the SAME binding if they agree on workspace id
|
|
219
219
|
* (canonical form) or on the literal key (legacy forms). Anything else — or
|
package/dist/lib/launcher.js
CHANGED
|
@@ -117,18 +117,35 @@ export function globalBinCandidates(prefix, win = WIN) {
|
|
|
117
117
|
: [join(prefix, "bin", "retasc")];
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
-
* A launcher safe to write into a SHARED file.
|
|
120
|
+
* A launcher safe to write into a SHARED file. ALWAYS the pinned npx form.
|
|
121
121
|
*
|
|
122
122
|
* The absolute-path form is correct for the machine that resolved it and wrong everywhere
|
|
123
123
|
* else: `./.mcp.json` is the secret-free marker the product describes as safe to commit,
|
|
124
124
|
* so a path under someone's home directory both leaks their username into a committed
|
|
125
|
-
* file and hands every teammate a command that does not exist on their machine.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
125
|
+
* file and hands every teammate a command that does not exist on their machine.
|
|
126
|
+
*
|
|
127
|
+
* RTSC-879 — the bare `retasc` is NOT the safe fallback this function used to treat it as.
|
|
128
|
+
* The old comment here said it "was at least portable", and that is only true across
|
|
129
|
+
* machines that happen to have a global install. A container has none. Measured
|
|
130
|
+
* 2026-09-09 in a Claude Code cloud session on a fresh clone of this repo: the committed
|
|
131
|
+
* marker said `command: "retasc"`, and the MCP server died at
|
|
132
|
+
* `ENOENT: Executable not found in $PATH: retasc` before it could do anything else. That
|
|
133
|
+
* marker was written by a laptop where `resolveLauncher` found the binary on a durable
|
|
134
|
+
* PATH and returned `how: "on-path"`, which this function then passed straight through as
|
|
135
|
+
* though on-PATH-here meant on-PATH-anywhere.
|
|
136
|
+
*
|
|
137
|
+
* So there is no `how` worth preserving. `absolute` names a path nobody else has,
|
|
138
|
+
* `on-path` and `installed` name a binary nobody else is guaranteed to have, and this
|
|
139
|
+
* file is committed ON PURPOSE (see `.gitignore`, and the pre-commit guard that keeps it
|
|
140
|
+
* secret-free). The only form true on every machine is the pinned npx one.
|
|
141
|
+
*
|
|
142
|
+
* COST, stated plainly: sessions using the committed marker now pay npx resolution at
|
|
143
|
+
* startup — about 6s cold, and roughly a second warm off the npx cache. That is the price
|
|
144
|
+
* of a marker that starts at all on a machine that is not the one that wrote it, and it
|
|
145
|
+
* is paid ONLY here. `resolveLauncher`'s fast direct form is still what goes into
|
|
146
|
+
* user-level configs, which never travel.
|
|
128
147
|
*/
|
|
129
|
-
export function portableLauncher(
|
|
130
|
-
if (r.how !== "absolute")
|
|
131
|
-
return r.launcher;
|
|
148
|
+
export function portableLauncher(_r, version) {
|
|
132
149
|
return { command: "npx", args: ["-y", `${PKG}@${version}`] };
|
|
133
150
|
}
|
|
134
151
|
/**
|
package/dist/lib/outcome.js
CHANGED
|
@@ -66,6 +66,15 @@ import { homedir } from "node:os";
|
|
|
66
66
|
export const CONTINUABLE_STATES = [
|
|
67
67
|
/** No session, and no attempt has been made yet. The browser door is available. */
|
|
68
68
|
"NEEDS_SIGN_IN",
|
|
69
|
+
/**
|
|
70
|
+
* RTSC-879 — a device grant HAS been started and is waiting on the human's click.
|
|
71
|
+
*
|
|
72
|
+
* Continuable, and the distinction from `NEEDS_SIGN_IN` is the whole point: there,
|
|
73
|
+
* nothing has happened and the agent should begin. Here a code is live, the human has
|
|
74
|
+
* been handed it, and re-starting would issue a second code that invalidates the one
|
|
75
|
+
* they are looking at. The agent's move is to run the same command again, unchanged.
|
|
76
|
+
*/
|
|
77
|
+
"SIGN_IN_PENDING",
|
|
69
78
|
/** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
|
|
70
79
|
"NEEDS_ORG",
|
|
71
80
|
/** Org settled. Zero projects, or more than one, so which project is unknown. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.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": {
|