@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/CHANGELOG.md +60 -0
- package/dist/auth.js +142 -2
- package/dist/commands/bind.js +168 -7
- package/dist/commands/claim.js +55 -2
- package/dist/commands/mcp.js +36 -0
- package/dist/config.js +3 -0
- package/dist/index.js +15 -1
- package/dist/lib/binding.js +49 -0
- package/dist/lib/browserLogin.js +80 -0
- package/dist/lib/fetchFile.js +222 -0
- package/dist/lib/outcome.js +102 -0
- package/dist/proxy.js +138 -1
- package/package.json +1 -1
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.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": {
|