@retasc/cli 1.21.1 → 1.23.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 +31 -0
- package/dist/commands/mcp.js +6 -1
- package/dist/lib/attachFile.js +207 -0
- package/dist/lib/watchdog.js +74 -13
- package/dist/proxy.js +131 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ 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.23.0 (2026-08-17)
|
|
10
|
+
|
|
11
|
+
- **RTSC-660** — the proxy attaches files for you. Attaching a file was the one Retasc write
|
|
12
|
+
an agent could not finish on its own: the server handed back an upload URL and told the
|
|
13
|
+
caller to POST the bytes with "your API key", which under MCP lives in the proxy, not in
|
|
14
|
+
the model. Agents were resorting to reading the key out of `~/.retasc/bindings.json`, and
|
|
15
|
+
harnesses were blocking that as credential harvesting. The proxy now serves
|
|
16
|
+
`save_attachment_file(issue, path, title?)` itself: it reads the file and uploads it with
|
|
17
|
+
the key it already holds, so nothing about the credential reaches the model and the bytes
|
|
18
|
+
never pass through its context. Readable paths are confined to a root — `RETASC_ATTACH_ROOT`
|
|
19
|
+
if set, otherwise the proxy's working directory — compared after resolving symlinks on both
|
|
20
|
+
sides, with `.git` and non-regular files refused, because reading a file on the agent's
|
|
21
|
+
behalf skips the harness's own file-access prompt. Every accepted read is logged to stderr
|
|
22
|
+
with its resolved path. Calls that pass `contentBase64` instead are forwarded to the server
|
|
23
|
+
untouched.
|
|
24
|
+
|
|
25
|
+
## 1.22.0 (2026-08-12)
|
|
26
|
+
|
|
27
|
+
- **RTSC-646** — the watchdog now says when it is **not** renewing a lease. Its lease set is
|
|
28
|
+
in-memory and built only from claim traffic this proxy saw, so a claim made in another
|
|
29
|
+
terminal, over direct HTTP MCP, or by a previous proxy before a harness restart was renewed
|
|
30
|
+
by nobody — silently, until the reclaimer took the issue away mid-build. Touching such a
|
|
31
|
+
lease now prints a warning naming the issue and what to do about it (`release_issue` is
|
|
32
|
+
exempt: it ends a lease rather than needing one). A heartbeat rejected as `UNAUTHORIZED` is
|
|
33
|
+
also called out loudly, once: that means the session's **credential** died, not the lease,
|
|
34
|
+
so every claim stops renewing at the same moment and each lapses at its own expiry. The
|
|
35
|
+
proxy keeps tracking and keeps trying, because re-minting the key inside the TTL recovers
|
|
36
|
+
all of them. The `mcp install --watchdog` text no longer stops at "no per-claim
|
|
37
|
+
heartbeats" — it names both bounds: the proxy must be running, and it only renews claims
|
|
38
|
+
it saw this session make.
|
|
39
|
+
|
|
9
40
|
## 1.21.1 (2026-08-10)
|
|
10
41
|
|
|
11
42
|
- **RTSC-643** — `retasc gate install` keys the gate to **this folder's** project, not the
|
package/dist/commands/mcp.js
CHANGED
|
@@ -186,7 +186,12 @@ export function installMcp(opts) {
|
|
|
186
186
|
}
|
|
187
187
|
console.log("\nWatchdog MCP config (Codex / OpenCode / any stdio MCP client):\n");
|
|
188
188
|
console.log(mcpProxyConfigBlock(opts.url, opts.key, resolved.launcher));
|
|
189
|
-
|
|
189
|
+
// RTSC-646: the old text stopped at "no per-claim heartbeats", which reads as
|
|
190
|
+
// "a claim never lapses while I'm working". It renews the claims THIS proxy saw
|
|
191
|
+
// you make, for as long as it is running — so name both bounds here, or the
|
|
192
|
+
// first surprise is the reclaimer taking an issue mid-build.
|
|
193
|
+
console.log("\nThe watchdog keeps claims alive automatically — it renews every claim it saw this session make, about every 10 minutes, so your agent needs no per-claim heartbeats. Long builds and test runs do NOT free a claim by themselves.");
|
|
194
|
+
console.log("Two things it does not cover, both safe (they fall back to the normal 30-minute lease timeout): if the proxy stops, renewal stops; and a claim made elsewhere — another terminal, direct HTTP MCP, or a previous proxy before a restart — is renewed by nobody. Heartbeat or checkpoint those yourself.");
|
|
190
195
|
return;
|
|
191
196
|
}
|
|
192
197
|
const res = tryClaudeCli(opts.url, opts.key, scope);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Local file attachment for the MCP proxy (RTSC-660) — path resolution + the tool it advertises.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: every other Retasc write is ONE MCP tool call, because the credential
|
|
4
|
+
// lives in the server and the model only names a tool. File upload was the exception.
|
|
5
|
+
// `prepare_attachment_upload` handed back a URL and told the caller to Bearer it with "your
|
|
6
|
+
// API key", which under MCP the model structurally does not have. What that produced in the
|
|
7
|
+
// field (the customer report behind RTSC-660): the agent shelled out to read
|
|
8
|
+
// `~/.retasc/bindings.json`, and its harness blocked that twice as credential harvesting.
|
|
9
|
+
// Our happy path required the agent to do something indistinguishable from an attack.
|
|
10
|
+
//
|
|
11
|
+
// The proxy is the one component that can close this without moving the credential: it
|
|
12
|
+
// already holds the key and already sees every JSON-RPC message. So the agent names a PATH,
|
|
13
|
+
// the proxy reads the bytes and POSTs them. The key never approaches the model and the file
|
|
14
|
+
// never passes through its context (a 300KB screenshot as base64 in a tool argument is ~100k
|
|
15
|
+
// output tokens, which is why the bytes-over-HTTP design exists in the first place).
|
|
16
|
+
//
|
|
17
|
+
// The cost of that convenience is the reason this module is mostly rules: a proxy-side read
|
|
18
|
+
// bypasses the harness's OWN file-access prompt. Whatever this module agrees to open, a model
|
|
19
|
+
// can attach with nobody approving it, so a prompt-injected agent told to "attach the config"
|
|
20
|
+
// would otherwise be a clean exfiltration primitive. The confinement below IS the security
|
|
21
|
+
// boundary, not hygiene — keep it strict, and keep it here where it is unit-tested, rather
|
|
22
|
+
// than inline in the proxy's I/O path.
|
|
23
|
+
import { realpathSync, statSync } from "node:fs";
|
|
24
|
+
import { basename, resolve, sep } from "node:path";
|
|
25
|
+
/** The one tool name for "attach a file", whichever transport can carry the bytes. The
|
|
26
|
+
* server publishes a base64 variant under this SAME name for clients with no proxy; when a
|
|
27
|
+
* proxy is present it overrides the entry in `tools/list` with the path form below. One
|
|
28
|
+
* name, the best shape the environment can actually support — the model never has to know
|
|
29
|
+
* which it got, it just reads the schema it was handed. */
|
|
30
|
+
export const ATTACH_TOOL_NAME = "save_attachment_file";
|
|
31
|
+
/** Matches the server's `MAX_ENCRYPTED_BLOB_BYTES`. Checked locally too so an oversized file
|
|
32
|
+
* fails before we spend the upload, and with a message that names the size. */
|
|
33
|
+
export const MAX_ATTACH_BYTES = 50 * 1024 * 1024;
|
|
34
|
+
/** Env var that widens the root a path may live under. Set it deliberately (a worktree
|
|
35
|
+
* layout, a screenshots dir); unset, the root is the proxy's cwd. */
|
|
36
|
+
export const ATTACH_ROOT_ENV = "RETASC_ATTACH_ROOT";
|
|
37
|
+
/**
|
|
38
|
+
* The directory a file must live under to be attachable. `RETASC_ATTACH_ROOT` if set (a
|
|
39
|
+
* human's deliberate choice — the fleet case is real: agents work in sibling worktrees, so
|
|
40
|
+
* the proxy's cwd is often the main checkout while the file is next door), else the proxy's
|
|
41
|
+
* cwd. Resolved through `realpathSync` so the containment check below compares like with
|
|
42
|
+
* like on macOS, where `/tmp` is a symlink to `/private/tmp` and a naive prefix test on the
|
|
43
|
+
* unresolved root rejects every legitimate path under it.
|
|
44
|
+
*/
|
|
45
|
+
export function attachRoot(env, cwd) {
|
|
46
|
+
const configured = (env[ATTACH_ROOT_ENV] ?? "").trim();
|
|
47
|
+
const raw = configured || cwd;
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return resolve(raw); // non-existent root: nothing will resolve inside it anyway
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a caller-supplied path against `root` and decide whether we are willing to read it.
|
|
57
|
+
*
|
|
58
|
+
* Refusals, and what each one is actually for:
|
|
59
|
+
* - outside `root` — the containment rule. Compared AFTER `realpathSync` on both sides, so a
|
|
60
|
+
* symlink inside the root pointing at `~/.ssh/id_rsa` is caught: resolving only the
|
|
61
|
+
* requested path (or neither) is the classic way this check is defeated.
|
|
62
|
+
* - anything under a `.git` directory — inside the root by construction, and `.git/config`
|
|
63
|
+
* routinely holds credentials in remote URLs. No legitimate attachment lives there.
|
|
64
|
+
* - not a regular file — a directory, fifo or device isn't an attachment, and reading a fifo
|
|
65
|
+
* would hang the proxy's stdio loop rather than fail.
|
|
66
|
+
* - empty / oversized — the server rejects both; failing here costs one round trip less and
|
|
67
|
+
* can say how big the file actually was.
|
|
68
|
+
*
|
|
69
|
+
* A `~` is deliberately NOT expanded: the home directory is precisely what the root confines
|
|
70
|
+
* away from, so silently reaching it would undo the boundary.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveAttachPath(input, root) {
|
|
73
|
+
const requested = typeof input === "string" ? input.trim() : "";
|
|
74
|
+
if (!requested)
|
|
75
|
+
return { ok: false, error: "path is required" };
|
|
76
|
+
if (requested.startsWith("~")) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `path "${requested}" starts with ~, which is not expanded. Attachable files must live ` +
|
|
80
|
+
`under ${root}; give a path inside it (relative paths resolve against it).`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const candidate = resolve(root, requested);
|
|
84
|
+
let real;
|
|
85
|
+
try {
|
|
86
|
+
real = realpathSync(candidate);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { ok: false, error: `no such file: ${candidate}` };
|
|
90
|
+
}
|
|
91
|
+
// `root + sep` would be "//" for a root of "/", which nothing starts with — so a root of
|
|
92
|
+
// "/" would refuse everything instead of allowing everything. Fail-closed is the right
|
|
93
|
+
// direction, but silently, and only for that one root; normalize instead.
|
|
94
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
95
|
+
if (real !== root && !real.startsWith(prefix)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `refusing to read ${real}: it resolves outside ${root}. Only files under that root can ` +
|
|
99
|
+
`be attached (set ${ATTACH_ROOT_ENV} to widen it deliberately).`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (real.split(sep).includes(".git")) {
|
|
103
|
+
return { ok: false, error: `refusing to read ${real}: paths inside a .git directory are not attachable.` };
|
|
104
|
+
}
|
|
105
|
+
let size;
|
|
106
|
+
try {
|
|
107
|
+
const st = statSync(real);
|
|
108
|
+
if (!st.isFile())
|
|
109
|
+
return { ok: false, error: `not a regular file: ${real}` };
|
|
110
|
+
size = st.size;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, error: `cannot stat ${real}` };
|
|
114
|
+
}
|
|
115
|
+
if (size === 0)
|
|
116
|
+
return { ok: false, error: `refusing to attach an empty file: ${real}` };
|
|
117
|
+
if (size > MAX_ATTACH_BYTES) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `file is ${size} bytes, over the ${MAX_ATTACH_BYTES}-byte attachment limit: ${real}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// The display name comes from what the caller ASKED for, not from the resolved target: a
|
|
124
|
+
// symlink's own name is the one the human recognizes, and it only ever becomes a label.
|
|
125
|
+
return { ok: true, path: real, filename: basename(candidate), size };
|
|
126
|
+
}
|
|
127
|
+
/** The tool the proxy advertises in place of (or in addition to) the server's base64 variant.
|
|
128
|
+
* It names the root in the description because the model has no other way to learn where it
|
|
129
|
+
* may read from, and a refusal it could have avoided costs a whole turn. */
|
|
130
|
+
export function localAttachToolDef(root) {
|
|
131
|
+
return {
|
|
132
|
+
name: ATTACH_TOOL_NAME,
|
|
133
|
+
description: "Attach a FILE on this machine to an issue, in ONE call. Give the path; your local retasc " +
|
|
134
|
+
"proxy reads the bytes and uploads them with the credential it already holds. You do NOT " +
|
|
135
|
+
"need an API key for this and must not go looking for one — the whole point is that the " +
|
|
136
|
+
`key stays in the proxy. Readable paths are confined to ${root} (relative paths resolve ` +
|
|
137
|
+
"against it, symlinks out of it are refused, as is anything under .git). For a plain URL " +
|
|
138
|
+
"rather than a file, use save_attachment instead.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
issue: { type: "string", description: "Issue ID, e.g. RTSC-12" },
|
|
143
|
+
path: { type: "string", description: `File path, absolute or relative to ${root}.` },
|
|
144
|
+
title: { type: "string", description: "Optional label; also the download name." },
|
|
145
|
+
},
|
|
146
|
+
required: ["issue", "path"],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Put the local tool into a `tools/list` result: replace the server's entry of the same name
|
|
152
|
+
* (so the agent sees the shape this environment can actually serve, not the base64 fallback)
|
|
153
|
+
* or append it when the server has none. Appending matters — the proxy only needs
|
|
154
|
+
* `prepare_attachment_upload` to do its job, so it can offer one-call attachment against a
|
|
155
|
+
* server deployed before the base64 variant existed.
|
|
156
|
+
*/
|
|
157
|
+
export function mergeAttachTool(tools, root) {
|
|
158
|
+
if (!Array.isArray(tools))
|
|
159
|
+
return tools;
|
|
160
|
+
const local = localAttachToolDef(root);
|
|
161
|
+
const idx = tools.findIndex((t) => t && typeof t === "object" && t.name === ATTACH_TOOL_NAME);
|
|
162
|
+
if (idx === -1)
|
|
163
|
+
return [...tools, local];
|
|
164
|
+
const merged = [...tools];
|
|
165
|
+
merged[idx] = local;
|
|
166
|
+
return merged;
|
|
167
|
+
}
|
|
168
|
+
/** Does this JSON-RPC message want a LOCAL file upload? Only a `path` argument does: an agent
|
|
169
|
+
* that sends `contentBase64` is using the server's variant and must be forwarded untouched,
|
|
170
|
+
* so an older or hand-written client keeps working through a newer proxy. */
|
|
171
|
+
export function isLocalAttachCall(msg) {
|
|
172
|
+
const m = msg;
|
|
173
|
+
if (!m || m.method !== "tools/call" || m.params?.name !== ATTACH_TOOL_NAME)
|
|
174
|
+
return false;
|
|
175
|
+
const args = m.params?.arguments;
|
|
176
|
+
return typeof args?.path === "string" && args.path.trim() !== "";
|
|
177
|
+
}
|
|
178
|
+
/** Build the upload URL: the server hands back a per-issue endpoint, we add the download-name
|
|
179
|
+
* hints. `filename` drives the stored name and the server's content-type inference; `title`
|
|
180
|
+
* is the human label. Both are query params because the endpoint takes the raw bytes as its
|
|
181
|
+
* entire body. */
|
|
182
|
+
export function uploadUrlWith(uploadUrl, filename, title) {
|
|
183
|
+
const sepChar = uploadUrl.includes("?") ? "&" : "?";
|
|
184
|
+
const parts = [`filename=${encodeURIComponent(filename)}`];
|
|
185
|
+
if (title && title.trim())
|
|
186
|
+
parts.push(`title=${encodeURIComponent(title.trim())}`);
|
|
187
|
+
return `${uploadUrl}${sepChar}${parts.join("&")}`;
|
|
188
|
+
}
|
|
189
|
+
/** Turn an upload HTTP failure into something the agent can act on. The endpoint answers in
|
|
190
|
+
* plain text, so the status is what carries the meaning; a bare "HTTP 402" tells an agent
|
|
191
|
+
* nothing it can relay to the human who alone can fix it. */
|
|
192
|
+
export function uploadFailureMessage(status, body) {
|
|
193
|
+
const detail = body.trim() ? ` — ${body.trim()}` : "";
|
|
194
|
+
switch (status) {
|
|
195
|
+
case 401:
|
|
196
|
+
return `upload rejected: this proxy's key no longer authenticates${detail}`;
|
|
197
|
+
case 402:
|
|
198
|
+
return (`upload refused for billing${detail}. PASS THIS TO YOUR HUMAN — you cannot fix it ` +
|
|
199
|
+
`yourself, only an owner can, and until they do every write will keep failing.`);
|
|
200
|
+
case 404:
|
|
201
|
+
return `upload rejected: no such issue in this project${detail}`;
|
|
202
|
+
case 413:
|
|
203
|
+
return `upload rejected: file too large${detail}`;
|
|
204
|
+
default:
|
|
205
|
+
return `upload failed with HTTP ${status}${detail}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/lib/watchdog.js
CHANGED
|
@@ -111,32 +111,93 @@ export function heartbeatRequest(rpcId, issueId, claimToken) {
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
114
|
+
* Does a tool result carry SERVER ERROR signal `code`? The shared matcher behind
|
|
115
|
+
* isClaimLost / isUnauthorized.
|
|
115
116
|
*
|
|
116
|
-
* The server throws `Error("CLAIM_LOST: …")`
|
|
117
|
-
* surfaces that as an MCP
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* lease and let the reclaimer hand our
|
|
117
|
+
* The server throws `Error("CLAIM_LOST: …")` / `Error("UNAUTHORIZED: …")`
|
|
118
|
+
* (convex/lib/claims.ts, convex/lib/auth.ts), and http.ts surfaces that as an MCP
|
|
119
|
+
* tool ERROR: `{ isError: true, content:[{text:"CLAIM_LOST: …"}] }`. Match ONLY that
|
|
120
|
+
* signal (or a top-level `error`/`code` field, for a future structured shape) —
|
|
121
|
+
* NEVER `JSON.stringify` the whole result and regex it, or a benign payload field
|
|
122
|
+
* that merely CONTAINS the code (an issue title, a checkpoint note quoting an error,
|
|
123
|
+
* a comment body) would silently drop a live lease and let the reclaimer hand our
|
|
124
|
+
* work to someone else (RTSC-150).
|
|
123
125
|
*/
|
|
124
|
-
|
|
126
|
+
function hasErrorCode(result, code) {
|
|
125
127
|
if (!result || typeof result !== "object")
|
|
126
128
|
return false;
|
|
127
129
|
const r = result;
|
|
130
|
+
// Substring match, NOT `new RegExp(code)`: the codes are fixed literals, so a
|
|
131
|
+
// regex buys nothing and turns any future caller-supplied code into a regex
|
|
132
|
+
// injection / ReDoS shape. `.includes` is the same match with none of that.
|
|
133
|
+
const carries = (s) => s.includes(code);
|
|
128
134
|
// A structured error signal on a top-level field (belt-and-suspenders; also the
|
|
129
135
|
// shape the older unit test asserts). Bounded to top-level scalars, not nested.
|
|
130
|
-
if (r.code ===
|
|
136
|
+
if (r.code === code)
|
|
131
137
|
return true;
|
|
132
|
-
if (typeof r.error === "string" &&
|
|
138
|
+
if (typeof r.error === "string" && carries(r.error))
|
|
133
139
|
return true;
|
|
134
140
|
// The live shape: only an MCP error envelope, and only its text blocks — success
|
|
135
|
-
// payloads (isError absent/false) are never treated as
|
|
141
|
+
// payloads (isError absent/false) are never treated as an error.
|
|
136
142
|
if (r.isError !== true || !Array.isArray(r.content))
|
|
137
143
|
return false;
|
|
138
144
|
return r.content.some((c) => c !== null &&
|
|
139
145
|
typeof c === "object" &&
|
|
140
146
|
typeof c.text === "string" &&
|
|
141
|
-
|
|
147
|
+
carries(c.text));
|
|
148
|
+
}
|
|
149
|
+
/** A heartbeat CLAIM_LOST means the lease is gone — stop tracking it. */
|
|
150
|
+
export function isClaimLost(result) {
|
|
151
|
+
return hasErrorCode(result, "CLAIM_LOST");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A heartbeat UNAUTHORIZED means the CREDENTIAL died, not the lease (RTSC-646).
|
|
155
|
+
* `resolveAuth` throws it when the key was revoked or rotated, its parent key was
|
|
156
|
+
* revoked, or the member was suspended/retired — so EVERY lease in this session
|
|
157
|
+
* stops renewing at once and each one lapses at its own expiry.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately NOT treated like CLAIM_LOST: the claims are still ours and still
|
|
160
|
+
* live server-side, so dropping them from the map would hide exactly the leases
|
|
161
|
+
* the human needs to hear about. We keep tracking, keep trying (a re-mint can fix
|
|
162
|
+
* it inside the TTL), and say so loudly.
|
|
163
|
+
*/
|
|
164
|
+
export function isUnauthorized(result) {
|
|
165
|
+
return hasErrorCode(result, "UNAUTHORIZED");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The renewal-visibility warning (RTSC-646): this session is touching a lease the
|
|
169
|
+
* watchdog is NOT renewing, so it runs on the bare 30-minute TTL.
|
|
170
|
+
*
|
|
171
|
+
* The lease map is in-memory and built ONLY from claim traffic this process saw, so
|
|
172
|
+
* a claim made in another terminal, over direct HTTP MCP, or by a previous proxy
|
|
173
|
+
* before a harness restart is renewed by nobody — and today that is silent until
|
|
174
|
+
* the reclaimer takes the issue away mid-work. Two observable tells, both meaning
|
|
175
|
+
* "we hold it, we aren't renewing it":
|
|
176
|
+
* 1. a request carrying a `claimToken` for an issue we don't track (the agent is
|
|
177
|
+
* hand-heartbeating/checkpointing a lease we never saw claimed), and
|
|
178
|
+
* 2. a `check_claim` answering `youHold: true` for an issue we don't track.
|
|
179
|
+
* Returns the warning text, or undefined when there's nothing to say. Pure so the
|
|
180
|
+
* decision is testable without a proxy; the caller does the printing.
|
|
181
|
+
*/
|
|
182
|
+
export function untrackedLeaseWarning(o, leases) {
|
|
183
|
+
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
184
|
+
if (!id || leases.has(id))
|
|
185
|
+
return undefined;
|
|
186
|
+
// `release_issue` also carries a claimToken, but it ENDS the lease. Warning
|
|
187
|
+
// "nothing is renewing it, heartbeat it yourself" about a claim the agent is
|
|
188
|
+
// deliberately handing back is advice for the opposite of what it just did.
|
|
189
|
+
if (o.toolName === "release_issue")
|
|
190
|
+
return undefined;
|
|
191
|
+
const holds = typeof o.args?.claimToken === "string" ||
|
|
192
|
+
(!!o.result &&
|
|
193
|
+
typeof o.result === "object" &&
|
|
194
|
+
o.result.youHold === true);
|
|
195
|
+
if (!holds)
|
|
196
|
+
return undefined;
|
|
197
|
+
return (`lease for ${id} is NOT tracked by this proxy — nothing is renewing it, so it ` +
|
|
198
|
+
`runs on the bare 30-minute TTL and the reclaimer will take it mid-work. ` +
|
|
199
|
+
`This happens when the claim was made in another terminal, over direct HTTP MCP, ` +
|
|
200
|
+
`or by a previous proxy before a restart. Heartbeat or checkpoint it yourself ` +
|
|
201
|
+
`(checkpoint is better — it renews AND leaves the handoff note), or release it ` +
|
|
202
|
+
`and re-claim through this session.`);
|
|
142
203
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// reclaims the lease (the correct default) — a broken watchdog is never worse than
|
|
7
7
|
// no watchdog. Self-enforcing: no proxy → no Retasc tools → can't orphan a lease.
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
9
10
|
import { hostname } from "node:os";
|
|
10
11
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
12
|
import { dirname, resolve } from "node:path";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { applyObservation, heartbeatRequest, isClaimLost, shouldReapOnClose, } from "./lib/watchdog.js";
|
|
14
|
+
import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
|
|
14
15
|
import { resolveConn } from "./lib/keystore.js";
|
|
15
16
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
16
17
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
18
|
+
import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
|
|
17
19
|
// RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
|
|
18
20
|
// the direct commands (claim/tidy/done) can never diverge. The proxy carries its
|
|
19
21
|
// binding in its own env (RETASC_MCP_KEY legacy, or RETASC_WORKSPACE → keystore).
|
|
@@ -23,12 +25,19 @@ const KEY = resolved.key;
|
|
|
23
25
|
const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
|
|
24
26
|
const leases = new Map();
|
|
25
27
|
let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
|
|
28
|
+
// RTSC-646: the credential-death warning fires ONCE with its full explanation. A
|
|
29
|
+
// revoked/rotated key fails every heartbeat of every lease, so repeating the whole
|
|
30
|
+
// paragraph on each tick (every 10 min, per lease) would bury the first one.
|
|
31
|
+
let authWarned = false;
|
|
26
32
|
// Per-session key (RTSC-50): starts as the workspace key; on startup we mint a
|
|
27
33
|
// session key and switch to it so this session is distinguishable from others.
|
|
28
34
|
let activeKey = KEY;
|
|
29
35
|
// Set when session-key minting failed and we fell back to the workspace key —
|
|
30
36
|
// whoami responses get a warning block appended so the agent sees the degraded state.
|
|
31
37
|
let sessionKeyFallback = false;
|
|
38
|
+
// RTSC-660: the directory a `save_attachment_file` path must live under. Resolved ONCE at
|
|
39
|
+
// startup, not per call, so the boundary can't be moved mid-session by anything the model says.
|
|
40
|
+
const ATTACH_ROOT = attachRoot(process.env, process.cwd());
|
|
32
41
|
// stderr only: stdout is the MCP channel and must carry ONLY protocol messages.
|
|
33
42
|
function log(msg) {
|
|
34
43
|
process.stderr.write(`[retasc-watchdog] ${msg}\n`);
|
|
@@ -175,6 +184,82 @@ async function announceBinding() {
|
|
|
175
184
|
function toolResult(resp, tool) {
|
|
176
185
|
return parseTool(resp, (msg) => log(tool ? `${tool}: ${msg}` : msg));
|
|
177
186
|
}
|
|
187
|
+
/** Write one JSON-RPC tool result back to the harness (the local half of a tools/call). */
|
|
188
|
+
function replyToolResult(id, text, isError) {
|
|
189
|
+
process.stdout.write(JSON.stringify({
|
|
190
|
+
jsonrpc: "2.0",
|
|
191
|
+
id,
|
|
192
|
+
result: { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) },
|
|
193
|
+
}) + "\n");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Serve `save_attachment_file` LOCALLY (RTSC-660): the agent names a path, we read the bytes
|
|
197
|
+
* and POST them with the key we already hold. This is the whole fix — it turns the one write
|
|
198
|
+
* an agent could not complete on its own into a single tool call, and removes the step where
|
|
199
|
+
* a well-behaved agent had to go reading a credential file off disk.
|
|
200
|
+
*
|
|
201
|
+
* The upload URL is fetched from the server rather than composed here, so the issue check,
|
|
202
|
+
* the project boundary and the endpoint's shape all stay server-authoritative and this side
|
|
203
|
+
* holds no policy it could get wrong. The path is validated first, before we spend a round
|
|
204
|
+
* trip on a file we would refuse to read anyway.
|
|
205
|
+
*
|
|
206
|
+
* Every accepted read is logged to stderr with its resolved path: a proxy-side read bypasses
|
|
207
|
+
* the harness's own file-access prompt, so the MCP log is where a human can see what was
|
|
208
|
+
* actually opened on their behalf.
|
|
209
|
+
*/
|
|
210
|
+
async function handleLocalAttach(msg) {
|
|
211
|
+
const args = (msg.params?.arguments ?? {});
|
|
212
|
+
const issue = typeof args.issue === "string" ? args.issue.trim() : "";
|
|
213
|
+
if (!issue)
|
|
214
|
+
return replyToolResult(msg.id, "issue is required (e.g. RTSC-12)", true);
|
|
215
|
+
const resolved = resolveAttachPath(String(args.path ?? ""), ATTACH_ROOT);
|
|
216
|
+
if (!resolved.ok) {
|
|
217
|
+
log(`refused attachment for ${issue}: ${resolved.error}`);
|
|
218
|
+
return replyToolResult(msg.id, resolved.error, true);
|
|
219
|
+
}
|
|
220
|
+
// Ask the server where this issue's bytes go. Doubles as the auth + issue-exists check, so
|
|
221
|
+
// a bad id or a foreign project fails before we open anything.
|
|
222
|
+
let prepared;
|
|
223
|
+
try {
|
|
224
|
+
prepared = toolResult(await postRemote({
|
|
225
|
+
jsonrpc: "2.0",
|
|
226
|
+
id: hbSeq--,
|
|
227
|
+
method: "tools/call",
|
|
228
|
+
params: { name: "prepare_attachment_upload", arguments: { issue } },
|
|
229
|
+
}), "prepare_attachment_upload");
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
return replyToolResult(msg.id, `could not reach Retasc: ${String(e?.message ?? e)}`, true);
|
|
233
|
+
}
|
|
234
|
+
const uploadUrl = typeof prepared?.uploadUrl === "string" ? prepared.uploadUrl : "";
|
|
235
|
+
if (!uploadUrl) {
|
|
236
|
+
// Relay what the server said rather than a generic failure: it is usually NOT_FOUND for
|
|
237
|
+
// this issue, and that is the sentence the agent needs to see.
|
|
238
|
+
const detail = typeof prepared === "string" ? prepared : JSON.stringify(prepared ?? null);
|
|
239
|
+
return replyToolResult(msg.id, `could not prepare an upload for ${issue}: ${detail}`, true);
|
|
240
|
+
}
|
|
241
|
+
const title = typeof args.title === "string" ? args.title : undefined;
|
|
242
|
+
log(`attaching ${resolved.path} (${resolved.size} bytes) to ${issue}`);
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(uploadUrlWith(uploadUrl, resolved.filename, title), {
|
|
245
|
+
method: "POST",
|
|
246
|
+
// No Content-Type on purpose: the server infers it from `filename`, and that inference
|
|
247
|
+
// is better than anything guessed here — declaring octet-stream would REPLACE it and
|
|
248
|
+
// cost the attachment its inline preview.
|
|
249
|
+
headers: { Authorization: `Bearer ${activeKey}` },
|
|
250
|
+
body: await readFile(resolved.path),
|
|
251
|
+
});
|
|
252
|
+
const body = await res.text();
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
log(`upload of ${resolved.filename} to ${issue} failed: HTTP ${res.status}`);
|
|
255
|
+
return replyToolResult(msg.id, uploadFailureMessage(res.status, body), true);
|
|
256
|
+
}
|
|
257
|
+
return replyToolResult(msg.id, body, false);
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return replyToolResult(msg.id, `upload failed: ${String(e?.message ?? e)}`, true);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
178
263
|
async function handleLine(line) {
|
|
179
264
|
const trimmed = line.trim();
|
|
180
265
|
if (!trimmed)
|
|
@@ -186,6 +271,11 @@ async function handleLine(line) {
|
|
|
186
271
|
catch {
|
|
187
272
|
return; // not a JSON-RPC message — ignore
|
|
188
273
|
}
|
|
274
|
+
// RTSC-660: a file attachment by PATH is served here, not forwarded — the bytes are on
|
|
275
|
+
// THIS machine and the credential is in this process. Anything else (including the same
|
|
276
|
+
// tool called with `contentBase64`, the server's own variant) goes remote untouched.
|
|
277
|
+
if (isLocalAttachCall(msg))
|
|
278
|
+
return await handleLocalAttach(msg);
|
|
189
279
|
let resp;
|
|
190
280
|
try {
|
|
191
281
|
resp = await postRemote(msg);
|
|
@@ -201,6 +291,14 @@ async function handleLine(line) {
|
|
|
201
291
|
}
|
|
202
292
|
return;
|
|
203
293
|
}
|
|
294
|
+
// RTSC-660: advertise the PATH form of save_attachment_file in place of the server's
|
|
295
|
+
// base64 one. A tool list is fetched once at startup, so this is the only moment we get to
|
|
296
|
+
// tell the agent which shape this environment can actually serve.
|
|
297
|
+
if (msg.method === "tools/list" && resp && typeof resp === "object") {
|
|
298
|
+
const result = resp.result;
|
|
299
|
+
if (result && Array.isArray(result.tools))
|
|
300
|
+
result.tools = mergeAttachTool(result.tools, ATTACH_ROOT);
|
|
301
|
+
}
|
|
204
302
|
// Watch tools/call traffic for claims/releases (request args + result), and
|
|
205
303
|
// flag the workspace-key fallback on whoami so the AGENT sees the degraded
|
|
206
304
|
// state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
|
|
@@ -212,6 +310,12 @@ async function handleLine(line) {
|
|
|
212
310
|
// ownership). Pure decision — see shouldReapOnClose. We act on it AFTER relaying
|
|
213
311
|
// the response, below (RTSC-181).
|
|
214
312
|
reapId = shouldReapOnClose(obs, leases);
|
|
313
|
+
// RTSC-646: warn BEFORE applyObservation, while the map still reflects what we
|
|
314
|
+
// were renewing when the call was made — after it, a fresh claim would look
|
|
315
|
+
// tracked and a deliberate release would look untracked.
|
|
316
|
+
const warning = untrackedLeaseWarning(obs, leases);
|
|
317
|
+
if (warning)
|
|
318
|
+
log(`warning: ${warning}`);
|
|
215
319
|
applyObservation(leases, obs);
|
|
216
320
|
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
217
321
|
}
|
|
@@ -227,11 +331,35 @@ async function handleLine(line) {
|
|
|
227
331
|
async function heartbeatAll() {
|
|
228
332
|
for (const [issueId, token] of [...leases]) {
|
|
229
333
|
try {
|
|
230
|
-
const
|
|
231
|
-
if (isClaimLost(
|
|
334
|
+
const result = toolResult(await postRemote(heartbeatRequest(hbSeq--, issueId, token)), "heartbeat");
|
|
335
|
+
if (isClaimLost(result)) {
|
|
232
336
|
leases.delete(issueId);
|
|
233
337
|
log(`lease for ${issueId} is gone — stopped tracking`);
|
|
234
338
|
}
|
|
339
|
+
else if (isUnauthorized(result)) {
|
|
340
|
+
// RTSC-646: the credential died, not the lease. Every claim in this session
|
|
341
|
+
// stops renewing at once and lapses at its own expiry, which reads exactly
|
|
342
|
+
// like "the watchdog is working" until the reclaimer takes the work away.
|
|
343
|
+
// Keep tracking and keep trying — re-minting the key inside the TTL recovers
|
|
344
|
+
// every lease — but say the cause out loud, once.
|
|
345
|
+
if (!authWarned) {
|
|
346
|
+
authWarned = true;
|
|
347
|
+
log(`warning: heartbeat for ${issueId} was rejected as UNAUTHORIZED — this session's key ` +
|
|
348
|
+
`no longer authenticates (revoked or rotated key, revoked parent key, or a suspended/` +
|
|
349
|
+
`retired member). NO lease in this session is being renewed now; each one lapses at ` +
|
|
350
|
+
`its own expiry and the reclaimer hands the work to another agent. Restore the ` +
|
|
351
|
+
`credential and restart this session, or finish and release your claims by hand.`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
log(`heartbeat for ${issueId} still UNAUTHORIZED — lease not renewed`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// A renewal that worked proves the credential works, so arm the full
|
|
359
|
+
// warning again: a key re-minted mid-session and then revoked a second
|
|
360
|
+
// time deserves the same loud explanation as the first time.
|
|
361
|
+
authWarned = false;
|
|
362
|
+
}
|
|
235
363
|
}
|
|
236
364
|
catch (e) {
|
|
237
365
|
log(`heartbeat for ${issueId} failed: ${String(e?.message ?? e)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.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": {
|