@retasc/cli 1.29.0 → 1.31.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/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.29.0",
3
+ "version": "1.31.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": {