@retasc/cli 1.28.0 → 1.30.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 CHANGED
@@ -6,6 +6,34 @@ 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.30.0 (2026-08-20)
10
+
11
+ - **RTSC-681** — your agent can read a file you attached to an issue. Uploading has
12
+ been one call since 1.24.0; reading one back needed an API key the model does not
13
+ have and is told not to go looking for, so a certificate attached for an agent to
14
+ verify was something it could see the name of and nothing else. `get_attachment_file`
15
+ closes it: name the attachment id, the proxy downloads it with the credential it
16
+ already holds and hands back a path to read. Any size, and the bytes never pass
17
+ through the model's context.
18
+ - **RTSC-681** — downloads land under `.retasc/attachments/` in your workspace, one
19
+ folder per attachment, never overwriting anything and never following a symlink, in
20
+ a directory that ignores itself so a customer's certificate cannot be committed by
21
+ accident. There is no destination argument on purpose: a write the proxy performs
22
+ skips the prompt your harness would otherwise show you, so it only ever writes to
23
+ the one place it owns. Copy the file where you want it and your normal tools ask you
24
+ first, which is the point.
25
+
26
+ ## 1.29.0 (2026-08-19)
27
+
28
+ - **RTSC-672** — every command we hand out now says `npx @retasc/cli@latest`. npx caches
29
+ by spec, so a bare `npx @retasc/cli` can keep serving whatever version you first ran —
30
+ which meant the people most likely to re-run a command, the ones who hit a bug and were
31
+ told it was fixed, were exactly the ones liable to be served the broken build again.
32
+ `@latest` is a tag, not a pin: it re-resolves every time and can never go stale, where a
33
+ version number written into docs absolutely can.
34
+ - **RTSC-672** — a first-run failure names the build that produced it, so "it still
35
+ doesn't work" and "you're running last week's CLI" stop looking identical.
36
+
9
37
  ## 1.28.0 (2026-08-19)
10
38
 
11
39
  - **RTSC-676** — signing in opens your browser. It used to print a URL and an
@@ -5,7 +5,7 @@ import { loadConfig, patchConfig } from "../config.js";
5
5
  import { installMarker, printMarkerBlock } from "./mcp.js";
6
6
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
7
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
8
- import { resolveLauncher, launcherNote, runsOk, selfCommand } from "../lib/launcher.js";
8
+ import { resolveLauncher, launcherNote, runsOk, selfCommand, versionStamp } from "../lib/launcher.js";
9
9
  import { ask, confirm, isInteractive } from "../lib/prompt.js";
10
10
  import { clean } from "../lib/text.js";
11
11
  import { card, DOT } from "../lib/card.js";
@@ -563,7 +563,11 @@ export async function ensureSignedIn() {
563
563
  if (loadConfig().token)
564
564
  return;
565
565
  if (!isInteractive()) {
566
- throw new Error(`Not signed in. Run \`${selfCommand(VERSION)} login\` first (no TTY here for the device flow).`);
566
+ // RTSC-672 the stamp rides in the HINT, not the message. `formatError` keeps only
567
+ // the first line of a message (deliberately, to strip stack noise), so a second line
568
+ // here would be silently dropped — which is how a diagnostic aimed at silent failures
569
+ // would itself fail silently.
570
+ cliError("UNAUTHENTICATED", `Not signed in. Run \`${selfCommand(VERSION)} login\` first (no TTY here for the device flow).`, versionStamp(VERSION).trim());
567
571
  }
568
572
  // RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
569
573
  // announcing "GitHub" before the question would be wrong for the invited
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { VERSION } from "./version.js";
4
- import { selfCommand } from "./lib/launcher.js";
4
+ import { selfCommand, versionStamp } from "./lib/launcher.js";
5
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
6
6
  import { installMcp, normalizeScope } from "./commands/mcp.js";
7
7
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
@@ -27,9 +27,13 @@ program
27
27
  function requireLogin() {
28
28
  if (!isLoggedIn()) {
29
29
  // RTSC-671 — `selfCommand` renders the form they actually used. The docs send a
30
- // new owner through `npx @retasc/cli`, which never puts `retasc` on PATH, so naming
30
+ // new owner through `npx @retasc/cli@latest`, which never puts `retasc` on PATH, so naming
31
31
  // it here answers a failure with a second `command not found`.
32
32
  console.error(`Not signed in. Run \`${selfCommand(VERSION)} login\` first.`);
33
+ // RTSC-672 — say which build spoke. A stale CLI and a broken one produce the same
34
+ // sentence otherwise, and the people most likely to re-run this are the ones who
35
+ // were just told a fix shipped.
36
+ console.error(versionStamp(VERSION));
33
37
  process.exit(1);
34
38
  }
35
39
  }
@@ -403,7 +407,7 @@ members
403
407
  // them in, binds the folder and wires their agent, so this must not tell them to run
404
408
  // `retasc login` first — nor name a `retasc` binary they don't have yet.
405
409
  console.log(` Share it. In the folder their agent works in, they run:`);
406
- console.log(` npx @retasc/cli join ${res.code}`);
410
+ console.log(` npx @retasc/cli@latest join ${res.code}`);
407
411
  }
408
412
  catch (e) {
409
413
  fail(e);
@@ -0,0 +1,222 @@
1
+ // Local file DOWNLOAD for the MCP proxy (RTSC-681) — the read counterpart to attachFile.ts.
2
+ //
3
+ // Why this exists: attachments were one-way. Uploading has been a single tool call since
4
+ // RTSC-660 (the agent names a path, the proxy uploads with the key it holds), but reading one
5
+ // back required `Authorization: Bearer <agent key>` on /attachments/download — a credential the
6
+ // model structurally does not have and is explicitly told not to hunt for. So a human could
7
+ // attach a certificate for an agent to verify and the agent could see its name and nothing
8
+ // else. The proxy already brokers bytes in one direction with a credential the model never
9
+ // touches; this is the same trade in reverse.
10
+ //
11
+ // The cost of the reverse trade is different, and worse, which is why this module is stricter
12
+ // than its sibling rather than a mirror image of it. attachFile.ts confines what may be READ,
13
+ // because a proxy-side read bypasses the harness's file-access prompt. A proxy-side WRITE
14
+ // bypasses the harness's write prompt, and a write is the more dangerous primitive: bytes we
15
+ // place in `.claude/settings.json`, `.githooks/`, or a workflow file become someone's next
16
+ // command execution. The filename is not ours either — it comes off an attachment row any org
17
+ // member (or an importer pulling from Jira, ClickUp or Asana) can influence.
18
+ //
19
+ // So this side takes no destination from anybody. Downloads land in ONE directory the proxy
20
+ // owns, under a sanitized name, never overwriting, never through a symlink. An agent that
21
+ // wants the file somewhere else copies it with its own file tools — which the harness DOES
22
+ // prompt on. That asymmetry is the whole argument: bypassing the read prompt was unavoidable
23
+ // to make upload work at all, bypassing the write prompt is not.
24
+ import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
25
+ import { basename, join, sep } from "node:path";
26
+ /** The one tool name for "read an attached file", whichever transport can carry the bytes.
27
+ * The server publishes an INLINE variant under this SAME name (bytes in the tool result,
28
+ * capped, an image as an image); when a proxy is present it overrides that entry in
29
+ * `tools/list` with the to-disk form below. One name, the best shape the environment can
30
+ * actually support — exactly as save_attachment_file works. */
31
+ export const FETCH_TOOL_NAME = "get_attachment_file";
32
+ /** Matches the server's blob ceiling (MAX_ENCRYPTED_BLOB_BYTES), so nothing storable is
33
+ * un-readable. Checked against what the server SAYS the size is, and again against what it
34
+ * actually sent — a length header is a claim, not a guarantee. */
35
+ export const MAX_FETCH_BYTES = 50 * 1024 * 1024;
36
+ /** Where downloads land, relative to the attach root. Inside the workspace on purpose: a
37
+ * harness that sandboxes file reads to the project would not be able to open anything we
38
+ * wrote to the OS temp dir, and a file the agent cannot read is the bug we are fixing. */
39
+ export const DOWNLOAD_DIR_SEGMENTS = [".retasc", "attachments"];
40
+ /** The directory the proxy owns for downloaded attachments. */
41
+ export function downloadDir(root) {
42
+ return join(root, ...DOWNLOAD_DIR_SEGMENTS);
43
+ }
44
+ /**
45
+ * Reduce a server-supplied name to something safe to create INSIDE a directory we own.
46
+ *
47
+ * Every rule here is about a name we did not choose: the row's filename came from whoever
48
+ * uploaded it, or from an importer copying a third-party tracker's field verbatim.
49
+ * - `basename` first, then a second sweep for separators — `../../.git/hooks/pre-commit` and
50
+ * an absolute path both collapse to a leaf, and a Windows `..\\` does too.
51
+ * - control characters and NUL, which truncate paths in some syscalls and lie in terminals.
52
+ * - a leading dot, so nothing we write can become a dotfile (`.gitignore`, `.npmrc`, `.env`)
53
+ * even inside our own directory.
54
+ * - Windows device names (CON, NUL, COM1…), which are not openable as files there.
55
+ * - length, so a long title can't push us past a filesystem limit and get truncated into a
56
+ * name that collides with something else.
57
+ * Empty after all that (a name made entirely of stripped characters) falls back to the id,
58
+ * which is unique by construction.
59
+ */
60
+ export function sanitizeDownloadName(name, attachmentId) {
61
+ const leaf = basename(String(name ?? "").replace(/[\\/]+/g, "/"));
62
+ let cleaned = leaf
63
+ .replace(/[\\/]/g, "_")
64
+ .replace(/[\u0000-\u001f\u007f]/g, "") // control characters and NUL, which lie in a terminal and truncate paths
65
+ .replace(/^\.+/, "") // no dotfiles, not even inside a directory we own
66
+ .trim()
67
+ .slice(0, 120);
68
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(cleaned))
69
+ cleaned = `file-${cleaned}`;
70
+ return cleaned || `attachment-${attachmentId}`;
71
+ }
72
+ /**
73
+ * Where `attachmentId`'s bytes go. One directory per attachment (ids are unique and immutable,
74
+ * so two files never contend for a name and a cached file is never the wrong file), under the
75
+ * proxy-owned root. The id is validated rather than sanitized: it comes from the server, it is
76
+ * an opaque token, and anything that isn't one is a bug or an attack, not a name to clean up.
77
+ * The containment check at the end is belt-and-braces — after the id check and the name
78
+ * sanitizer there is no known way to escape, which is exactly when a cheap assertion earns its
79
+ * place.
80
+ */
81
+ export function resolveDownloadTarget(root, attachmentId, filename) {
82
+ const id = String(attachmentId ?? "").trim();
83
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) {
84
+ return { ok: false, error: `refusing to write: "${id}" is not a valid attachment id` };
85
+ }
86
+ const dir = join(downloadDir(root), id);
87
+ const name = sanitizeDownloadName(filename, id);
88
+ const path = join(dir, name);
89
+ if (path !== join(dir, basename(path)) || !path.startsWith(dir + sep)) {
90
+ return { ok: false, error: `refusing to write ${path}: it resolves outside ${dir}` };
91
+ }
92
+ return { ok: true, dir, path, filename: name };
93
+ }
94
+ /**
95
+ * The size of an already-downloaded file, or null if there isn't one. `lstat`, never `stat`:
96
+ * a symlink at this path would make `stat` report the target's size and the agent read
97
+ * whatever it points at, so a symlink counts as "not our file" and is reported as such.
98
+ *
99
+ * A file being present IS a complete download — writes go through a temp file and a rename
100
+ * below, so a crash mid-write can never leave a short file at the final path.
101
+ */
102
+ export function existingDownload(path) {
103
+ let st;
104
+ try {
105
+ st = lstatSync(path);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ if (st.isSymbolicLink())
111
+ return { symlink: true };
112
+ if (!st.isFile())
113
+ return null;
114
+ return { size: st.size };
115
+ }
116
+ /**
117
+ * Write the downloaded bytes, atomically and without ever following a symlink.
118
+ *
119
+ * `wx` on the temp file so we only ever create, never clobber; `rename` to publish, which
120
+ * REPLACES a symlink sitting at the destination rather than writing through it (the one
121
+ * escape a plain write would allow). A leftover temp file from a killed proxy is removed
122
+ * first — it is inside a directory only this code writes to, and its name is ours.
123
+ */
124
+ export function writeDownloadedFile(target, bytes, root) {
125
+ mkdirSync(target.dir, { recursive: true, mode: 0o700 });
126
+ ensureCacheIgnored(root);
127
+ const tmp = `${target.path}.part-${process.pid}`;
128
+ try {
129
+ unlinkSync(tmp);
130
+ }
131
+ catch {
132
+ /* nothing to clean up */
133
+ }
134
+ writeFileSync(tmp, bytes, { flag: "wx", mode: 0o600 });
135
+ renameSync(tmp, target.path);
136
+ }
137
+ /**
138
+ * Make the download directory ignore itself, once.
139
+ *
140
+ * A customer's certificate, production log or crash dump lands in their working tree, and the
141
+ * agent working there is about to commit something. A `.gitignore` holding `*` inside the
142
+ * directory covers everything in it (itself included) without touching the repo's own
143
+ * `.gitignore`, which is a file we have no business editing.
144
+ */
145
+ export function ensureCacheIgnored(root) {
146
+ const dir = downloadDir(root);
147
+ try {
148
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
149
+ writeFileSync(join(dir, ".gitignore"), "*\n", { flag: "wx", mode: 0o600 });
150
+ }
151
+ catch {
152
+ /* already there (the common case), or unwritable — never fail a download over this */
153
+ }
154
+ }
155
+ /** The tool the proxy advertises in place of the server's inline variant. It names the
156
+ * directory because the model has no other way to learn where the file will appear, and
157
+ * says the bytes never enter the context so nobody "helpfully" asks for base64 instead. */
158
+ export function localFetchToolDef(root) {
159
+ return {
160
+ name: FETCH_TOOL_NAME,
161
+ description: "Read a FILE attached to an issue, in ONE call. Give the attachment id (from " +
162
+ "list_attachments); your local retasc proxy downloads it with the credential it already " +
163
+ "holds and writes it to disk, then hands you the path — read it with your normal file " +
164
+ "tools. You do NOT need an API key for this and must not go looking for one, and you " +
165
+ "must not curl the download URL yourself: the whole point is that the key stays in the " +
166
+ `proxy. Files land under ${downloadDir(root)} (one folder per attachment, never ` +
167
+ "overwritten, git-ignored); copy it elsewhere yourself if you want it kept. Works at any " +
168
+ "size up to the 50MB attachment limit, and the bytes never pass through your context. " +
169
+ "For a link attachment (one saved with save_attachment) there is nothing to download — " +
170
+ "fetch its URL yourself.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ attachment: { type: "string", description: "Attachment id, e.g. from list_attachments." },
175
+ },
176
+ required: ["attachment"],
177
+ },
178
+ };
179
+ }
180
+ /**
181
+ * Put the local tool into a `tools/list` result: replace the server's entry of the same name
182
+ * (so the agent sees the shape this environment can actually serve, not the inline fallback)
183
+ * or append it when the server has none. Appending matters — the proxy needs only
184
+ * `get_attachment` to do its job, so it can offer to-disk downloads against a server deployed
185
+ * before the inline variant existed.
186
+ */
187
+ export function mergeFetchTool(tools, root) {
188
+ if (!Array.isArray(tools))
189
+ return tools;
190
+ const local = localFetchToolDef(root);
191
+ const idx = tools.findIndex((t) => t && typeof t === "object" && t.name === FETCH_TOOL_NAME);
192
+ if (idx === -1)
193
+ return [...tools, local];
194
+ const merged = [...tools];
195
+ merged[idx] = local;
196
+ return merged;
197
+ }
198
+ /** Does this JSON-RPC message want a LOCAL download? Any call to the tool does: unlike the
199
+ * upload pair there is no second argument shape to tell them apart, and when a proxy is
200
+ * present writing to disk is always the better answer — it costs the agent no context and
201
+ * has no size ceiling. */
202
+ export function isLocalFetchCall(msg) {
203
+ const m = msg;
204
+ return !!m && m.method === "tools/call" && m.params?.name === FETCH_TOOL_NAME;
205
+ }
206
+ /** Turn a download HTTP failure into something the agent can act on — same contract as the
207
+ * upload side: the status carries the meaning, and a bare "HTTP 402" tells an agent nothing
208
+ * it can relay to the human who alone can fix it. */
209
+ export function downloadFailureMessage(status, body) {
210
+ const detail = body.trim() ? ` — ${body.trim()}` : "";
211
+ switch (status) {
212
+ case 401:
213
+ return `download rejected: this proxy's key no longer authenticates${detail}`;
214
+ case 402:
215
+ return (`download refused for billing${detail}. PASS THIS TO YOUR HUMAN — you cannot fix it ` +
216
+ `yourself, only an owner can, and until they do every call will keep failing.`);
217
+ case 404:
218
+ return `download rejected: no such attachment in this project, or it has no stored file${detail}`;
219
+ default:
220
+ return `download failed with HTTP ${status}${detail}`;
221
+ }
222
+ }
@@ -96,6 +96,25 @@ export function portableLauncher(r, version) {
96
96
  export function selfCommand(version, argv1 = process.argv[1] ?? "") {
97
97
  return /[\\/]_npx[\\/]/.test(argv1) ? `npx -y ${PKG}@${version}` : "retasc";
98
98
  }
99
+ /**
100
+ * A one-line stamp of which build just spoke (RTSC-672).
101
+ *
102
+ * A failure report is only diagnosable if it says what produced it. After 1.26.0 fixed
103
+ * sign-in, a container kept printing the 1.25.0 message and the only way to learn why was
104
+ * a round trip asking someone to run `--version` — because nothing in the output said.
105
+ * Worse, the population most likely to re-run a command is the population that hit a bug
106
+ * and was told it is fixed, which is exactly the population liable to be holding a stale
107
+ * build. "It still doesn't work" and "you are running last week's CLI" look identical
108
+ * without this.
109
+ *
110
+ * The EXACT version, not `@latest`: this names the code that emitted the message, which
111
+ * is a different question from how to fetch the current one. `npx` in the prefix when
112
+ * that is how they started, so the line doubles as a runnable command.
113
+ */
114
+ export function versionStamp(version, argv1 = process.argv[1] ?? "") {
115
+ const how = /[\\/]_npx[\\/]/.test(argv1) ? `npx ${PKG}@${version}` : `${PKG} ${version}`;
116
+ return ` (${how})`;
117
+ }
99
118
  /** Install (or upgrade to) an exact version globally. Returns null on success. */
100
119
  function installGlobal(version) {
101
120
  // A cold global install takes seconds with no output of its own. Silence here reads as
package/dist/proxy.js CHANGED
@@ -16,6 +16,7 @@ import { resolveConn } from "./lib/keystore.js";
16
16
  import { toolResult as parseTool } from "./lib/toolresult.js";
17
17
  import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
18
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
19
+ import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
19
20
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
20
21
  // the direct commands (claim/tidy/done) can never diverge. The proxy carries its
21
22
  // binding in its own env (RETASC_MCP_KEY legacy, or RETASC_WORKSPACE → keystore).
@@ -260,6 +261,132 @@ async function handleLocalAttach(msg) {
260
261
  return replyToolResult(msg.id, `upload failed: ${String(e?.message ?? e)}`, true);
261
262
  }
262
263
  }
264
+ /**
265
+ * Serve `get_attachment_file` LOCALLY (RTSC-681): the agent names an attachment id, we fetch
266
+ * the bytes with the key we already hold and write them to a file the agent can open. The
267
+ * mirror of handleLocalAttach above, and the other half of the same fix — attachments were a
268
+ * one-way street, and the way back required a credential the model is told it must not go
269
+ * looking for.
270
+ *
271
+ * The metadata comes from the server rather than being assumed here, for the same reason the
272
+ * upload asks for its URL: the auth check, the project boundary and "is there even a file"
273
+ * stay server-authoritative, so this side holds no policy it could get wrong. Only then do we
274
+ * spend a download.
275
+ *
276
+ * Every write is logged to stderr with its resolved path. A proxy-side write bypasses the
277
+ * harness's own file-write prompt, so the MCP log is where a human can see what appeared on
278
+ * their disk on their behalf.
279
+ */
280
+ async function handleLocalFetch(msg) {
281
+ const args = (msg.params?.arguments ?? {});
282
+ const attachment = typeof args.attachment === "string" ? args.attachment.trim() : "";
283
+ if (!attachment) {
284
+ return replyToolResult(msg.id, "attachment is required (an attachment id from list_attachments)", true);
285
+ }
286
+ // Ask the server what this is. Doubles as the auth + existence + project-boundary check, so
287
+ // a bad id or a foreign project fails before we write anything.
288
+ let meta;
289
+ try {
290
+ meta = toolResult(await postRemote({
291
+ jsonrpc: "2.0",
292
+ id: hbSeq--,
293
+ method: "tools/call",
294
+ params: { name: "get_attachment", arguments: { attachment } },
295
+ }), "get_attachment");
296
+ }
297
+ catch (e) {
298
+ return replyToolResult(msg.id, `could not reach Retasc: ${String(e?.message ?? e)}`, true);
299
+ }
300
+ const url = typeof meta?.url === "string" ? meta.url : "";
301
+ if (!url) {
302
+ // Relay what the server said rather than a generic failure: it is usually NOT_FOUND for
303
+ // this id, and that is the sentence the agent needs to see.
304
+ const detail = typeof meta === "string" ? meta : JSON.stringify(meta ?? null);
305
+ return replyToolResult(msg.id, `could not read attachment ${attachment}: ${detail}`, true);
306
+ }
307
+ // Whether there are bytes is the SERVER's answer, never inferred from the URL. An earlier
308
+ // draft fell back to matching the download path in the URL when `hasFile` was absent, which
309
+ // is a credential leak: a LINK attachment's URL is arbitrary text any org member can write,
310
+ // so `https://evil.example/attachments/download?x=1` would have matched and this fetch would
311
+ // have carried our Bearer token to their host. A server too old to answer gets an error that
312
+ // names the fix, which is the safe direction to fail.
313
+ if (meta.hasFile !== true) {
314
+ const why = meta.hasFile === false
315
+ ? `attachment ${attachment} is a link, not an uploaded file — there are no bytes to download. ` +
316
+ `Fetch it yourself if you can reach it: ${url}`
317
+ : `this Retasc deployment is too old to serve get_attachment_file (its get_attachment does ` +
318
+ `not report whether an attachment has a file). Ask your human to update the backend.`;
319
+ return replyToolResult(msg.id, why, true);
320
+ }
321
+ // Belt and braces on the URL the server handed back: only ever send the credential to the
322
+ // endpoint this feature is about, over http(s), never to a `file:`/`data:` scheme or some
323
+ // other path that a future server-side bug might emit here.
324
+ let parsed;
325
+ try {
326
+ parsed = new URL(url);
327
+ }
328
+ catch {
329
+ return replyToolResult(msg.id, `could not read attachment ${attachment}: the server returned an unusable URL`, true);
330
+ }
331
+ // `endsWith`, not `===`: a deployment whose site URL carries a path prefix serves the same
332
+ // endpoint under it, and rejecting that would break a legitimate install to catch nothing.
333
+ if (!/^https?:$/.test(parsed.protocol) || !parsed.pathname.endsWith("/attachments/download")) {
334
+ log(`refused download of ${attachment}: unexpected download URL ${parsed.origin}${parsed.pathname}`);
335
+ return replyToolResult(msg.id, `refusing to download ${attachment}: the server returned an unexpected download URL.`, true);
336
+ }
337
+ const declared = typeof meta.bytes === "number" ? meta.bytes : undefined;
338
+ if (declared !== undefined && declared > MAX_FETCH_BYTES) {
339
+ return replyToolResult(msg.id, `attachment ${attachment} is ${declared} bytes, over the ${MAX_FETCH_BYTES}-byte limit`, true);
340
+ }
341
+ const target = resolveDownloadTarget(ATTACH_ROOT, attachment, meta.filename ?? meta.title);
342
+ if (!target.ok) {
343
+ log(`refused download of ${attachment}: ${target.error}`);
344
+ return replyToolResult(msg.id, target.error, true);
345
+ }
346
+ const describe = (bytes, cached) => JSON.stringify({
347
+ attachment,
348
+ issue: meta.issue,
349
+ path: target.path,
350
+ filename: target.filename,
351
+ bytes,
352
+ contentType: meta.contentType,
353
+ obsolete: meta.obsolete ?? false,
354
+ cached,
355
+ note: "The file is on disk — read it with your normal file tools. Copy it elsewhere yourself if you want to keep it; this directory is git-ignored and is not cleaned up for you.",
356
+ }, null, 2);
357
+ // An attachment id maps to immutable bytes, so a file already sitting at this exact path IS
358
+ // this attachment — hand it back rather than paying for the same egress twice.
359
+ const existing = existingDownload(target.path);
360
+ if (existing && "symlink" in existing) {
361
+ const error = `refusing to use ${target.path}: it is a symlink, not a downloaded file. Remove it and try again.`;
362
+ log(error);
363
+ return replyToolResult(msg.id, error, true);
364
+ }
365
+ if (existing) {
366
+ log(`${attachment} already downloaded → ${target.path}`);
367
+ return replyToolResult(msg.id, describe(existing.size, true), false);
368
+ }
369
+ try {
370
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${activeKey}` } });
371
+ if (!res.ok) {
372
+ const body = await res.text();
373
+ log(`download of ${attachment} failed: HTTP ${res.status}`);
374
+ return replyToolResult(msg.id, downloadFailureMessage(res.status, body), true);
375
+ }
376
+ const bytes = new Uint8Array(await res.arrayBuffer());
377
+ // The declared size was a claim; this is what actually arrived. Check it before it lands
378
+ // on disk — a response is not bounded by what the metadata said it would be.
379
+ if (bytes.byteLength > MAX_FETCH_BYTES) {
380
+ return replyToolResult(msg.id, `download aborted: ${bytes.byteLength} bytes exceeds the ${MAX_FETCH_BYTES}-byte limit`, true);
381
+ }
382
+ writeDownloadedFile(target, bytes, ATTACH_ROOT);
383
+ log(`downloaded ${attachment} (${bytes.byteLength} bytes) → ${target.path}`);
384
+ return replyToolResult(msg.id, describe(bytes.byteLength, false), false);
385
+ }
386
+ catch (e) {
387
+ return replyToolResult(msg.id, `download failed: ${String(e?.message ?? e)}`, true);
388
+ }
389
+ }
263
390
  async function handleLine(line) {
264
391
  const trimmed = line.trim();
265
392
  if (!trimmed)
@@ -276,6 +403,11 @@ async function handleLine(line) {
276
403
  // tool called with `contentBase64`, the server's own variant) goes remote untouched.
277
404
  if (isLocalAttachCall(msg))
278
405
  return await handleLocalAttach(msg);
406
+ // RTSC-681: the read counterpart. Whenever a proxy is present, downloading to disk beats
407
+ // the server's inline variant on every axis (no size ceiling, no bytes in the model's
408
+ // context), so every call to the tool is served here rather than forwarded.
409
+ if (isLocalFetchCall(msg))
410
+ return await handleLocalFetch(msg);
279
411
  let resp;
280
412
  try {
281
413
  resp = await postRemote(msg);
@@ -296,8 +428,13 @@ async function handleLine(line) {
296
428
  // tell the agent which shape this environment can actually serve.
297
429
  if (msg.method === "tools/list" && resp && typeof resp === "object") {
298
430
  const result = resp.result;
299
- if (result && Array.isArray(result.tools))
431
+ if (result && Array.isArray(result.tools)) {
300
432
  result.tools = mergeAttachTool(result.tools, ATTACH_ROOT);
433
+ // RTSC-681: same override for the read half — one name, the shape this environment can
434
+ // actually serve, decided at the only moment we get to tell the agent (tools/list is
435
+ // fetched once, at startup).
436
+ result.tools = mergeFetchTool(result.tools, ATTACH_ROOT);
437
+ }
301
438
  }
302
439
  // Watch tools/call traffic for claims/releases (request args + result), and
303
440
  // flag the workspace-key fallback on whoami so the AGENT sees the degraded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Retasc CLI \u2014 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": {