@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 +28 -0
- package/dist/commands/bind.js +6 -2
- package/dist/index.js +7 -3
- package/dist/lib/fetchFile.js +222 -0
- package/dist/lib/launcher.js +19 -0
- package/dist/proxy.js +138 -1
- package/package.json +1 -1
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
|
package/dist/commands/bind.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/lib/launcher.js
CHANGED
|
@@ -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.
|
|
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": {
|