@retasc/cli 1.48.1 → 1.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/dist/auth.js +117 -0
- package/dist/commands/bind.js +119 -3
- package/dist/commands/doctor.js +32 -4
- package/dist/config.js +42 -0
- package/dist/lib/launcher.js +24 -7
- package/dist/lib/outcome.js +9 -0
- package/dist/proxy.js +26 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,56 @@ 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.1 (2026-09-09)
|
|
10
|
+
|
|
11
|
+
- **RTSC-879** — the proxy sends NO `Authorization` header when it has no key, instead of
|
|
12
|
+
`Bearer ` with nothing after it. An empty bearer is not a weaker credential, it is a
|
|
13
|
+
malformed one, and where a keyless proxy now runs that difference decides whether the
|
|
14
|
+
thing works at all: in a Claude Code cloud container the documented way to supply a
|
|
15
|
+
credential is an environment API credential, which Anthropic's egress proxy injects as
|
|
16
|
+
an `Authorization` header after the request leaves the VM — and which also lets the
|
|
17
|
+
request through a network allowlist that would otherwise refuse the host. Injection
|
|
18
|
+
wants the header absent, and whether it overwrites one already present is undocumented,
|
|
19
|
+
so sending an empty bearer staked the whole path on undocumented behaviour. With a key
|
|
20
|
+
present nothing changes.
|
|
21
|
+
|
|
22
|
+
## 1.49.0 (2026-09-09)
|
|
23
|
+
|
|
24
|
+
- **RTSC-879** — a committed `.mcp.json` now names `npx -y @retasc/cli@<version> mcp-proxy`
|
|
25
|
+
instead of a bare `retasc`, so it starts on a machine that is not the one that wrote it.
|
|
26
|
+
`portableLauncher` used to pass an on-PATH resolution straight through on the belief that
|
|
27
|
+
a bare binary "was at least portable"; it is portable only across machines that happen to
|
|
28
|
+
have a global install. A container has none. Measured in a Claude Code cloud session on a
|
|
29
|
+
fresh clone: the MCP server died at `ENOENT: Executable not found in $PATH: retasc` before
|
|
30
|
+
it could do anything else. Startup on the shared marker now pays npx resolution (about 6s
|
|
31
|
+
cold, ~1s off the cache); user-level configs, which never travel, keep the direct binary.
|
|
32
|
+
- **RTSC-879** — `retasc bind --json` can now sign in from a container. An agent-driven
|
|
33
|
+
bind takes the DEVICE grant instead of the browser one, in two steps: the first call
|
|
34
|
+
prints an approve URL and an eight-character code and **exits**, the human approves on
|
|
35
|
+
any device, and running the same command again resumes the same grant and finishes.
|
|
36
|
+
Previously an agent got the browser door, which redirects to `127.0.0.1` on the machine
|
|
37
|
+
running the CLI — unreachable when the human is somewhere else, which in a cloud
|
|
38
|
+
container they always are. New continuable outcome state `SIGN_IN_PENDING` so an agent
|
|
39
|
+
can tell "waiting on a click" from "nothing has started" and does not issue a second
|
|
40
|
+
code that invalidates the one its human is reading. CI and `RETASC_NO_BROWSER` still
|
|
41
|
+
refuse immediately: neither has anyone to approve anything.
|
|
42
|
+
- **RTSC-879** — `retasc doctor` no longer installs a package to check a marker. For an
|
|
43
|
+
npx marker it probes `npx --version` rather than executing the pinned package spec,
|
|
44
|
+
which was a registry fetch on every run and reported "your agent CANNOT start Retasc"
|
|
45
|
+
for a healthy marker whenever npm was unreachable. Its remedy line is corrected too: a
|
|
46
|
+
global install can no longer change what `bind` writes into a shared marker.
|
|
47
|
+
- **RTSC-879** — the repo's own committed `.mcp.json`, and the marker blocks published in
|
|
48
|
+
`web/auth.md` and the agent-skill, now show the npx form. They were the bare binary,
|
|
49
|
+
which is the exact thing this release stops emitting. Those hand-written blocks say
|
|
50
|
+
`@latest` rather than a pinned version on purpose: `release:cli` publishes from `main`
|
|
51
|
+
AFTER a merge, so a committed marker pinning the version being released would 404 for
|
|
52
|
+
every fresh clone in the window between the two — trading this release's ENOENT for an
|
|
53
|
+
E404. `bind` still writes the pinned form, which is correct because it only ever names
|
|
54
|
+
a version that already exists.
|
|
55
|
+
- **[no-issue]** — test fix: the setup-hook test pinned `RETASC_HOME` to a temp dir. It
|
|
56
|
+
fell through to the real home directory, so it passed on a clean CI runner and failed
|
|
57
|
+
on any machine that had actually run `retasc setup`.
|
|
58
|
+
|
|
9
59
|
## 1.48.1 (2026-09-08)
|
|
10
60
|
|
|
11
61
|
- **RTSC-878** — `retasc doctor` no longer condemns the entry `retasc setup` writes. Its
|
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
|
@@ -44,6 +44,25 @@ export function launcherVerdict(local, probe = runsOk) {
|
|
|
44
44
|
// The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
|
|
45
45
|
const args = local.args ?? [];
|
|
46
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
|
+
}
|
|
47
66
|
return {
|
|
48
67
|
startable: Boolean(probe(local.command, probeArgs)),
|
|
49
68
|
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
@@ -149,10 +168,19 @@ function checkLauncher(local) {
|
|
|
149
168
|
ok(`your agent can start Retasc (${v.shown}).`);
|
|
150
169
|
return;
|
|
151
170
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.`);
|
|
156
184
|
}
|
|
157
185
|
export async function doctorAction() {
|
|
158
186
|
const cfg = loadConfig();
|
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/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/dist/proxy.js
CHANGED
|
@@ -172,11 +172,34 @@ function runReap(issueId) {
|
|
|
172
172
|
child.unref?.(); // a pending reap must not keep the proxy alive
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* The auth header, or NOTHING when this proxy has no key (RTSC-879).
|
|
177
|
+
*
|
|
178
|
+
* `Bearer ` with an empty value is not a weaker credential, it is a malformed one: the
|
|
179
|
+
* request is worse off for carrying it than for leaving it out. What made that matter is
|
|
180
|
+
* where a keyless proxy now runs. In a Claude Code cloud container there is no keystore
|
|
181
|
+
* and no `RETASC_MCP_KEY`, and the documented way to give that container a credential is
|
|
182
|
+
* an environment API credential, which Anthropic's egress proxy INJECTS as an
|
|
183
|
+
* `Authorization` header after the request leaves the VM — and which also lets the
|
|
184
|
+
* request through a network allowlist that would otherwise refuse the host outright.
|
|
185
|
+
*
|
|
186
|
+
* Injection wants an absent header. Whether that proxy overwrites one already present is
|
|
187
|
+
* not documented, so sending an empty Bearer bets the whole path on undocumented
|
|
188
|
+
* behaviour. Omitting it needs no such bet.
|
|
189
|
+
*
|
|
190
|
+
* Nothing else changes: with a key this returns exactly what these call sites sent
|
|
191
|
+
* before, and a keyless request still fails at Retasc the way it always did — it just
|
|
192
|
+
* fails honestly, as unauthenticated, rather than as a credential that is present and
|
|
193
|
+
* blank.
|
|
194
|
+
*/
|
|
195
|
+
function authHeader() {
|
|
196
|
+
return activeKey ? { Authorization: `Bearer ${activeKey}` } : {};
|
|
197
|
+
}
|
|
175
198
|
async function postRemote(body) {
|
|
176
199
|
const res = await fetch(MCP_URL, {
|
|
177
200
|
method: "POST",
|
|
178
201
|
headers: {
|
|
179
|
-
|
|
202
|
+
...authHeader(),
|
|
180
203
|
"Content-Type": "application/json",
|
|
181
204
|
Accept: "application/json",
|
|
182
205
|
},
|
|
@@ -465,7 +488,7 @@ async function handleLocalAttach(msg) {
|
|
|
465
488
|
// No Content-Type on purpose: the server infers it from `filename`, and that inference
|
|
466
489
|
// is better than anything guessed here — declaring octet-stream would REPLACE it and
|
|
467
490
|
// cost the attachment its inline preview.
|
|
468
|
-
headers:
|
|
491
|
+
headers: authHeader(),
|
|
469
492
|
body: bytes,
|
|
470
493
|
});
|
|
471
494
|
const body = await res.text();
|
|
@@ -585,7 +608,7 @@ async function handleLocalFetch(msg) {
|
|
|
585
608
|
return replyToolResult(msg.id, describe(existing.size, true), false);
|
|
586
609
|
}
|
|
587
610
|
try {
|
|
588
|
-
const res = await fetch(url, { headers:
|
|
611
|
+
const res = await fetch(url, { headers: authHeader() });
|
|
589
612
|
if (!res.ok) {
|
|
590
613
|
const body = await res.text();
|
|
591
614
|
log(`download of ${attachment} failed: HTTP ${res.status}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.1",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|