@retasc/cli 1.2.0 → 1.2.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/dist/lib/claim.js CHANGED
@@ -5,6 +5,10 @@
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { resolveConn } from "./keystore.js";
8
+ // Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
9
+ // re-exported so existing importers of this module keep working.
10
+ import { toolResult } from "./toolresult.js";
11
+ export { toolResult };
8
12
  const DEFAULT_MCP_URL = "https://mcp.retasc.com/mcp";
9
13
  /**
10
14
  * Resolve the agent API key + MCP URL the same way the workspace's MCP wiring does,
@@ -84,16 +88,6 @@ export function parseClaimResult(result) {
84
88
  const title = typeof r.issue === "object" ? r.issue?.title : undefined;
85
89
  return { issueId, title, claimToken: r.claimToken };
86
90
  }
87
- /** The tool payload (JSON inside result.content[0].text), or the raw result. */
88
- export function toolResult(resp) {
89
- try {
90
- const t = resp?.result?.content?.[0]?.text;
91
- return t ? JSON.parse(t) : resp?.result;
92
- }
93
- catch {
94
- return resp?.result;
95
- }
96
- }
97
91
  /**
98
92
  * Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
99
93
  * and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
@@ -0,0 +1,93 @@
1
+ // Session-key adoption core (RTSC-143) — the mint call the proxy runs on startup
2
+ // (RTSC-50), extracted here so the retry + failure behavior is unit-testable with
3
+ // a stubbed fetch. Fail-soft stands: a broken mint must never take the MCP down —
4
+ // but the fallback must be LOUD, because on the shared workspace key concurrent
5
+ // sessions collapse into one server-side identity and the per-session claim fence
6
+ // (RTSC-49/50) is silently inert.
7
+ import { toolResult } from "./toolresult.js";
8
+ // Out-of-band ids are negative so they never collide with the harness's (the
9
+ // same convention as the proxy's heartbeat ids; announceBinding uses -1001).
10
+ const MINT_RPC_ID = -1000;
11
+ // The mint runs BEFORE the proxy serves `initialize`, so a hung server must not
12
+ // stall startup past the harness's MCP timeout — that would turn "degraded
13
+ // identity" into "no Retasc tools at all", the exact failure fail-soft exists
14
+ // to avoid. Two attempts fit comfortably inside a ~30s harness budget.
15
+ const MINT_TIMEOUT_MS = 5_000;
16
+ const FALLBACK_CONSEQUENCE = "concurrent sessions will be indistinguishable to the server and the " +
17
+ "per-session claim fence is OFF for this session. Restart the session to retry.";
18
+ /**
19
+ * Mint a per-session key, retrying once (a startup network blip must not demote
20
+ * the whole session's identity). Never throws. On double failure, emits an
21
+ * unmissable multi-line warning stating the consequence and returns {ok:false}
22
+ * so the caller can keep flagging the degraded state (e.g. on whoami).
23
+ */
24
+ export async function mintSessionKey(opts) {
25
+ const fetchImpl = opts.fetchImpl ?? fetch;
26
+ const warn = opts.warn ?? ((msg) => process.stderr.write(`[retasc] ${msg}\n`));
27
+ const retryDelayMs = opts.retryDelayMs ?? 500;
28
+ for (let attempt = 1; attempt <= 2; attempt++) {
29
+ try {
30
+ const res = await fetchImpl(opts.url, {
31
+ method: "POST",
32
+ headers: {
33
+ Authorization: `Bearer ${opts.key}`,
34
+ "Content-Type": "application/json",
35
+ Accept: "application/json",
36
+ },
37
+ body: JSON.stringify({
38
+ jsonrpc: "2.0",
39
+ id: MINT_RPC_ID,
40
+ method: "tools/call",
41
+ params: { name: "mint_session_key", arguments: { label: opts.label } },
42
+ }),
43
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
44
+ });
45
+ const text = await res.text();
46
+ // A non-2xx (revoked key → 401, server error → 5xx) must not masquerade
47
+ // as "no key in response" — surface the status so a dead key is obvious.
48
+ if (!res.ok) {
49
+ const snippet = text ? `: ${text.slice(0, 120).replace(/\s+/g, " ").trim()}` : "";
50
+ warn(`mint_session_key attempt ${attempt} failed (HTTP ${res.status})${snippet}`);
51
+ }
52
+ else {
53
+ const r = toolResult(text ? JSON.parse(text) : null, warn);
54
+ if (r && typeof r === "object" && typeof r.key === "string") {
55
+ return { ok: true, key: r.key, session: r.session };
56
+ }
57
+ warn(`mint_session_key attempt ${attempt} returned no key`);
58
+ }
59
+ }
60
+ catch (e) {
61
+ warn(`mint_session_key attempt ${attempt} failed: ${String(e?.message ?? e)}`);
62
+ }
63
+ if (attempt === 1 && retryDelayMs > 0) {
64
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
65
+ }
66
+ }
67
+ warn("WARNING: could not mint a per-session key (retried once).");
68
+ warn(`WARNING: continuing on the SHARED WORKSPACE KEY — ${FALLBACK_CONSEQUENCE}`);
69
+ return { ok: false };
70
+ }
71
+ /**
72
+ * The warning content block appended to whoami responses when the session is
73
+ * running on the workspace-key fallback — so the AGENT sees the degraded state,
74
+ * not just whoever reads the MCP log files.
75
+ */
76
+ export const MINT_FALLBACK_NOTICE = "⚠ Retasc watchdog: this session could not mint a per-session key and is using " +
77
+ `the shared workspace key — ${FALLBACK_CONSEQUENCE}`;
78
+ /**
79
+ * Append MINT_FALLBACK_NOTICE to a whoami response when degraded. Defensive on
80
+ * purpose: anything unexpected about the shape (error response, missing/non-array
81
+ * content) leaves the response untouched — a malformed notice must never break
82
+ * the protocol stream this rides on.
83
+ */
84
+ export function appendFallbackNotice(toolName, resp, degraded) {
85
+ if (!degraded || toolName !== "whoami")
86
+ return;
87
+ const result = resp?.result;
88
+ if (result?.isError)
89
+ return; // don't decorate an error response
90
+ if (Array.isArray(result?.content)) {
91
+ result.content.push({ type: "text", text: MINT_FALLBACK_NOTICE });
92
+ }
93
+ }
@@ -0,0 +1,94 @@
1
+ // Tolerant MCP tool-result parsing (RTSC-143) — pure, shared by proxy.ts and
2
+ // lib/claim.ts (previously two silent near-identical copies). The RTSC-91 footer
3
+ // regression survived unnoticed because a payload of `{json}\n\n— footer` failed
4
+ // JSON.parse and the parser silently handed back the raw result envelope —
5
+ // downstream code (lease tracking, claim parsing) then quietly misbehaved. This
6
+ // parser (a) recovers the JSON prefix when the text has trailing non-JSON, and
7
+ // (b) says so, so a shape regression is visible in MCP logs instead of vanishing.
8
+ /** Pure core: classify + extract the payload from a JSON-RPC tools/call response. */
9
+ export function parseToolResult(resp) {
10
+ const result = resp?.result;
11
+ const text = result?.content?.[0]?.text;
12
+ if (typeof text !== "string" || !text)
13
+ return { kind: "raw", value: result };
14
+ try {
15
+ return { kind: "json", value: JSON.parse(text) };
16
+ }
17
+ catch {
18
+ /* fall through to prefix recovery */
19
+ }
20
+ // Never RECOVER from an error response: error text is prose that may embed
21
+ // JSON-looking fragments (issue ids, tokens), and a recovered fragment would
22
+ // flow into lease tracking as if the call had succeeded.
23
+ if (!result?.isError) {
24
+ const recovered = parseJsonPrefix(text);
25
+ if (recovered)
26
+ return { kind: "recovered", value: recovered.value };
27
+ }
28
+ return { kind: "raw", value: result, unparseable: true };
29
+ }
30
+ const MAX_PREFIX_ATTEMPTS = 16;
31
+ /**
32
+ * "Strip trailing junk after the top-level JSON value". Fast path: JSON.parse
33
+ * errors name the offending position (V8/JSC), which for a value-plus-footer is
34
+ * exactly where the junk starts. Fallback: a bounded backward scan for the
35
+ * payload's closing `}`/`]`. Either way a candidate is only accepted when the
36
+ * discarded tail is NOT itself JSON-like — concatenated JSON values (NDJSON
37
+ * style) are a shape break where silently dropping the second value would be
38
+ * worse than the raw fallback.
39
+ */
40
+ function parseJsonPrefix(text) {
41
+ if (!/^\s*[{[]/.test(text))
42
+ return undefined; // only object/array payloads
43
+ const accept = (end) => {
44
+ if (/^\s*[{[]/.test(text.slice(end)))
45
+ return undefined; // tail is more JSON — refuse
46
+ try {
47
+ return { value: JSON.parse(text.slice(0, end)) };
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ try {
54
+ JSON.parse(text);
55
+ }
56
+ catch (e) {
57
+ const m = /position\s+(\d+)/i.exec(String(e?.message ?? e));
58
+ if (m) {
59
+ const hit = accept(Number(m[1]));
60
+ if (hit)
61
+ return hit;
62
+ }
63
+ }
64
+ let end = text.length;
65
+ for (let attempt = 0; attempt < MAX_PREFIX_ATTEMPTS; attempt++) {
66
+ const close = Math.max(text.lastIndexOf("}", end - 1), text.lastIndexOf("]", end - 1));
67
+ if (close < 0)
68
+ return undefined;
69
+ const hit = accept(close + 1);
70
+ if (hit)
71
+ return hit;
72
+ end = close; // that candidate wasn't the payload's end — try the previous one
73
+ }
74
+ return undefined;
75
+ }
76
+ function warnStderr(msg) {
77
+ process.stderr.write(`[retasc] ${msg}\n`);
78
+ }
79
+ /**
80
+ * The tool payload (JSON inside result.content[0].text), or the raw result.
81
+ * Drop-in for the old silent copies, except a degraded parse now WARNS (stderr —
82
+ * stdout may be the MCP channel): callers that only care about the value keep
83
+ * their one-liner, and a server shape regression is at least visible in logs.
84
+ */
85
+ export function toolResult(resp, warn = warnStderr) {
86
+ const p = parseToolResult(resp);
87
+ if (p.kind === "recovered") {
88
+ warn("tool result had trailing non-JSON text — recovered the leading JSON payload");
89
+ }
90
+ else if (p.unparseable) {
91
+ warn("tool result text is not JSON — falling back to the raw result envelope");
92
+ }
93
+ return p.value;
94
+ }
package/dist/proxy.js CHANGED
@@ -9,6 +9,8 @@ import { createInterface } from "node:readline";
9
9
  import { hostname } from "node:os";
10
10
  import { applyObservation, heartbeatRequest, isClaimLost, } from "./lib/watchdog.js";
11
11
  import { resolveConn } from "./lib/keystore.js";
12
+ import { toolResult as parseTool } from "./lib/toolresult.js";
13
+ import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
12
14
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
13
15
  // the direct commands (claim/tidy/done) can never diverge. The proxy carries its
14
16
  // binding in its own env (RETASC_MCP_KEY legacy, or RETASC_WORKSPACE → keystore).
@@ -21,6 +23,9 @@ let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with
21
23
  // Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
22
24
  // session key and switch to it so this session is distinguishable from others.
23
25
  let activeKey = KEY;
26
+ // Set when session-key minting failed and we fell back to the workspace key —
27
+ // whoami responses get a warning block appended so the agent sees the degraded state.
28
+ let sessionKeyFallback = false;
24
29
  // stderr only: stdout is the MCP channel and must carry ONLY protocol messages.
25
30
  function log(msg) {
26
31
  process.stderr.write(`[retasc-watchdog] ${msg}\n`);
@@ -42,30 +47,20 @@ async function postRemote(body) {
42
47
  * Auto-adopt a per-session key (RTSC-50). Mint one bound to our identity and use
43
48
  * it for the rest of the session, so concurrent sessions of the same agent are
44
49
  * distinguishable. Fail-soft: if minting is unavailable (older server, error),
45
- * keep using the workspace key — no worse than before.
50
+ * keep using the workspace key — no worse than before — but LOUDLY (RTSC-143),
51
+ * because on the shared key the per-session claim fence is inert.
46
52
  */
47
53
  async function adoptSessionKey() {
48
54
  if (!KEY)
49
55
  return;
50
56
  const label = process.env.RETASC_SESSION_LABEL || `${hostname()}#${process.pid}`;
51
- try {
52
- const resp = await postRemote({
53
- jsonrpc: "2.0",
54
- id: -1000,
55
- method: "tools/call",
56
- params: { name: "mint_session_key", arguments: { label } },
57
- });
58
- const r = toolResult(resp);
59
- if (r && typeof r === "object" && typeof r.key === "string") {
60
- activeKey = r.key; // switch to the session key
61
- log(`adopted session key "${r.session ?? label}"`);
62
- }
63
- else {
64
- log("mint_session_key unavailable — using the workspace key");
65
- }
57
+ const outcome = await mintSessionKey({ url: MCP_URL, key: KEY, label, warn: log });
58
+ if (outcome.ok) {
59
+ activeKey = outcome.key; // switch to the session key
60
+ log(`adopted session key "${outcome.session ?? label}"`);
66
61
  }
67
- catch (e) {
68
- log(`session-key mint failed (${String(e?.message ?? e)}) — using the workspace key`);
62
+ else {
63
+ sessionKeyFallback = true;
69
64
  }
70
65
  }
71
66
  /**
@@ -81,7 +76,7 @@ async function announceBinding() {
81
76
  method: "tools/call",
82
77
  params: { name: "whoami", arguments: {} },
83
78
  });
84
- const r = toolResult(resp);
79
+ const r = toolResult(resp, "whoami");
85
80
  if (r?.org && r?.project) {
86
81
  log(`bound → org "${r.org.name}" / project ${r.project.prefix}`);
87
82
  }
@@ -90,15 +85,11 @@ async function announceBinding() {
90
85
  /* announcement is best-effort */
91
86
  }
92
87
  }
93
- // The tool result payload — the JSON inside result.content[0].text or the raw result.
94
- function toolResult(resp) {
95
- try {
96
- const t = resp?.result?.content?.[0]?.text;
97
- return t ? JSON.parse(t) : resp?.result;
98
- }
99
- catch {
100
- return resp?.result;
101
- }
88
+ // The tool result payload — shared tolerant parser (RTSC-143), warning through
89
+ // our stderr logger (tool-labeled, so a busy MCP log names which call degraded)
90
+ // when it had to recover a JSON prefix or fall back raw.
91
+ function toolResult(resp, tool) {
92
+ return parseTool(resp, (msg) => log(tool ? `${tool}: ${msg}` : msg));
102
93
  }
103
94
  async function handleLine(line) {
104
95
  const trimmed = line.trim();
@@ -126,13 +117,16 @@ async function handleLine(line) {
126
117
  }
127
118
  return;
128
119
  }
129
- // Watch tools/call traffic for claims/releases (request args + result).
120
+ // Watch tools/call traffic for claims/releases (request args + result), and
121
+ // flag the workspace-key fallback on whoami so the AGENT sees the degraded
122
+ // state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
130
123
  if (msg.method === "tools/call" && resp) {
131
124
  applyObservation(leases, {
132
125
  toolName: msg.params?.name,
133
126
  args: msg.params?.arguments,
134
- result: toolResult(resp),
127
+ result: toolResult(resp, msg.params?.name),
135
128
  });
129
+ appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
136
130
  }
137
131
  // Relay the response (requests have an id; notifications don't).
138
132
  if (resp != null && msg.id !== undefined) {
@@ -143,7 +137,7 @@ async function heartbeatAll() {
143
137
  for (const [issueId, token] of [...leases]) {
144
138
  try {
145
139
  const resp = await postRemote(heartbeatRequest(hbSeq--, issueId, token));
146
- if (isClaimLost(toolResult(resp))) {
140
+ if (isClaimLost(toolResult(resp, "heartbeat"))) {
147
141
  leases.delete(issueId);
148
142
  log(`lease for ${issueId} is gone — stopped tracking`);
149
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Retasc CLI — sign in with GitHub, 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": {