@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/index.js
CHANGED
|
@@ -5,7 +5,7 @@ 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";
|
|
8
|
-
import { claimAction } from "./commands/claim.js";
|
|
8
|
+
import { claimAction, releaseAction } from "./commands/claim.js";
|
|
9
9
|
import { bindAction, setupFromToken } from "./commands/bind.js";
|
|
10
10
|
import { joinAction } from "./commands/join.js";
|
|
11
11
|
import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
|
|
@@ -195,6 +195,13 @@ program
|
|
|
195
195
|
// RTSC-495 — the agent's door. Everything this command normally asks was already
|
|
196
196
|
// answered in the Dash, so the token stands in for all of it and nothing is prompted.
|
|
197
197
|
.option("--setup <token>", "Complete setup from a Dash setup code — no sign-in, no prompts")
|
|
198
|
+
// RTSC-713 — the OTHER agent door, and the one that needs no Dash trip first.
|
|
199
|
+
//
|
|
200
|
+
// Three behaviours, deliberately on one flag because none is useful alone: NDJSON
|
|
201
|
+
// outcomes on stdout, the browser door without a TTY, and continuable states reported
|
|
202
|
+
// at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
|
|
203
|
+
// that leads to a thrown Error, is each worse than neither.
|
|
204
|
+
.option("--json", "Report machine-readable outcomes (for an agent driving setup)")
|
|
198
205
|
.action(async (opts) => {
|
|
199
206
|
// BEFORE requireLogin: the whole point is a machine that has never signed in. The
|
|
200
207
|
// token is the authorization, and asking for a session here would refuse every
|
|
@@ -593,6 +600,13 @@ program
|
|
|
593
600
|
// Normalize --only to the canonical uppercase id so it matches the branch ids
|
|
594
601
|
// scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
|
|
595
602
|
tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
|
|
603
|
+
program
|
|
604
|
+
.command("release [issue]")
|
|
605
|
+
.description("Hand a claimed issue back to the queue. Leaves your worktree and branch alone.")
|
|
606
|
+
.option("--id <RTSC-NN>", "The issue to release (or pass it positionally)")
|
|
607
|
+
.requiredOption("--claim-token <token>", "The claim token printed when you claimed it (the server fences on it)")
|
|
608
|
+
.option("--note <text>", "Handoff note for whoever picks it up next")
|
|
609
|
+
.action((issueArg, opts) => releaseAction({ issueArg, ...opts }).catch(fail));
|
|
596
610
|
program
|
|
597
611
|
.command("done")
|
|
598
612
|
.description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
|
package/dist/lib/binding.js
CHANGED
|
@@ -119,6 +119,55 @@ export function readShadowedBinding(dir) {
|
|
|
119
119
|
return undefined;
|
|
120
120
|
return parseServerEntry(folderEntry(dir), "folder");
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Is this a PLACEHOLDER entry — a bare URL with no credential of any kind (RTSC-691)?
|
|
124
|
+
*
|
|
125
|
+
* RTSC-678's flow starts by pointing the client at `https://mcp.retasc.com/mcp` with
|
|
126
|
+
* nothing else in it, so the server can answer the handshake with setup instructions.
|
|
127
|
+
* That entry is not a binding: it cannot authenticate, and every tool call through it
|
|
128
|
+
* fails. It exists to be replaced by the real one `bind` writes.
|
|
129
|
+
*
|
|
130
|
+
* It has to be recognised so it can be REMOVED, because scope precedence would otherwise
|
|
131
|
+
* make it win. `claude mcp add -t http` writes Claude-local scope by default; a `bind
|
|
132
|
+
* --scope project` writes `./.mcp.json`; and claude-local beats the folder marker at
|
|
133
|
+
* runtime (see the precedence note at the top of this file). Left in place, the finished
|
|
134
|
+
* setup would sit in a file nothing reads while the credential-less entry took every
|
|
135
|
+
* call — a setup that looks done and works nowhere.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately narrow: a `url` and NO `command`, no env credential, no Authorization
|
|
138
|
+
* header. Anything carrying a credential, or spawning something, is somebody's real
|
|
139
|
+
* configuration and is never silently removed.
|
|
140
|
+
*/
|
|
141
|
+
export function isPlaceholderEntry(raw, knownUrls = PLACEHOLDER_URLS) {
|
|
142
|
+
if (!raw || typeof raw !== "object")
|
|
143
|
+
return false;
|
|
144
|
+
if (typeof raw.url !== "string" || raw.url.length === 0)
|
|
145
|
+
return false;
|
|
146
|
+
if (typeof raw.command === "string")
|
|
147
|
+
return false;
|
|
148
|
+
if (raw.env && Object.keys(raw.env).length > 0)
|
|
149
|
+
return false;
|
|
150
|
+
// ANY header, not just Authorization. A gateway that injects credentials under some
|
|
151
|
+
// other name is still somebody's real configuration.
|
|
152
|
+
if (raw.headers && Object.keys(raw.headers).length > 0)
|
|
153
|
+
return false;
|
|
154
|
+
// And it must be the bootstrap URL we ourselves tell people to paste. This is the
|
|
155
|
+
// clause that matters: Claude Code stores MCP OAuth tokens OUTSIDE the server entry,
|
|
156
|
+
// keyed by server name, so a fully authorized OAuth binding is byte-identical to a
|
|
157
|
+
// placeholder by shape alone. RTSC-678 — this issue's parent — is about adding exactly
|
|
158
|
+
// that OAuth door, so shape-only matching would have grown a way to silently delete a
|
|
159
|
+
// working binding at the moment we shipped one.
|
|
160
|
+
return knownUrls.includes(normalizeUrl(raw.url));
|
|
161
|
+
}
|
|
162
|
+
/** The bare URLs we hand out for bootstrapping, normalized. */
|
|
163
|
+
const PLACEHOLDER_URLS = ["https://mcp.retasc.com/mcp"];
|
|
164
|
+
function normalizeUrl(u) {
|
|
165
|
+
return u.trim().replace(/\/+$/, "").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
/** Is there a placeholder in Claude Code's LOCAL scope for this folder? */
|
|
168
|
+
export function hasClaudeLocalPlaceholder(dir) {
|
|
169
|
+
return isPlaceholderEntry(claudeLocalRetascEntry(dir));
|
|
170
|
+
}
|
|
122
171
|
/** A user-scope (global) Retasc server: top-level in Claude Code's config,
|
|
123
172
|
* NOT under projects[dir]. Illegal under DESIGN §13 override #1 — it applies
|
|
124
173
|
* to every folder that has no binding of its own, so issues can land in the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
/** How long to wait for the human to click Approve. Generous: they may have to sign in
|
|
4
|
+
* to the Dash first, and possibly through GitHub or Google after that. */
|
|
5
|
+
const WAIT_MS = 5 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Open a loopback listener and return the URL to send the browser to, plus a promise
|
|
8
|
+
* that resolves when the browser comes back.
|
|
9
|
+
*
|
|
10
|
+
* Split in two on purpose. The caller needs the URL BEFORE the wait starts, so it can
|
|
11
|
+
* print it — a printed URL is what makes this work when the browser fails to open, which
|
|
12
|
+
* is the common case on a headless Linux box where `xdg-open` is absent.
|
|
13
|
+
*/
|
|
14
|
+
export async function startBrowserLogin(dashUrl) {
|
|
15
|
+
const state = randomBytes(24).toString("hex");
|
|
16
|
+
let resolve;
|
|
17
|
+
let reject;
|
|
18
|
+
const settled = new Promise((res, rej) => {
|
|
19
|
+
resolve = res;
|
|
20
|
+
reject = rej;
|
|
21
|
+
});
|
|
22
|
+
// The timeout below rejects this promise whether or not anyone is awaiting it. A caller
|
|
23
|
+
// that takes `url`/`cancel` and never calls `wait()` would otherwise get an unhandled
|
|
24
|
+
// rejection, which is fatal on Node 15+. Attaching an inert handler at construction
|
|
25
|
+
// makes the promise safe to ignore; `wait()` still sees the real rejection.
|
|
26
|
+
settled.catch(() => { });
|
|
27
|
+
const server = createServer((req, res) => {
|
|
28
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
29
|
+
const code = url.searchParams.get("code");
|
|
30
|
+
const gotState = url.searchParams.get("state");
|
|
31
|
+
// The browser is a person's, so answer it in a way a person can read. Whatever
|
|
32
|
+
// happens here, the tab is finished with.
|
|
33
|
+
const reply = (status, body) => {
|
|
34
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
35
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Retasc</title>` +
|
|
36
|
+
`<body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${body}</body>`);
|
|
37
|
+
};
|
|
38
|
+
if (!code || gotState !== state) {
|
|
39
|
+
// A mismatched nonce means this redirect did not come from the flow we started.
|
|
40
|
+
// The code is not forwarded, so it expires unspent two minutes later.
|
|
41
|
+
reply(400, "<h1>Sign-in could not be verified</h1><p>Close this tab and run <code>retasc login</code> again.</p>");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
reply(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
|
|
45
|
+
resolve({ code });
|
|
46
|
+
});
|
|
47
|
+
await new Promise((res, rej) => {
|
|
48
|
+
server.once("error", rej);
|
|
49
|
+
// Port 0: the OS picks a free one. Hard-coding a port would collide with a second
|
|
50
|
+
// `retasc login` and with whatever else the developer is running.
|
|
51
|
+
server.listen(0, "127.0.0.1", res);
|
|
52
|
+
});
|
|
53
|
+
const port = server.address().port;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
reject(new Error(`TIMEOUT: no response from the browser within ${WAIT_MS / 60_000} minutes`));
|
|
56
|
+
}, WAIT_MS);
|
|
57
|
+
// Do not hold the process open on this timer alone.
|
|
58
|
+
timer.unref?.();
|
|
59
|
+
const close = () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
// `close()` stops accepting but leaves the browser's idle keep-alive socket open, and
|
|
62
|
+
// that handle holds the event loop until Node's 5s keepAliveTimeout fires — so the
|
|
63
|
+
// CLI would appear to hang for five seconds after a successful sign-in.
|
|
64
|
+
server.closeAllConnections?.();
|
|
65
|
+
server.close();
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
url: `${dashUrl.replace(/\/+$/, "")}/cli-auth?port=${port}&state=${encodeURIComponent(state)}`,
|
|
69
|
+
port,
|
|
70
|
+
wait: async () => {
|
|
71
|
+
try {
|
|
72
|
+
return await settled;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
close();
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
cancel: close,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `bind` reports when an AGENT is driving it (RTSC-713).
|
|
3
|
+
*
|
|
4
|
+
* # Why this exists
|
|
5
|
+
*
|
|
6
|
+
* An agent decides whether to keep going from three signals, and all three are
|
|
7
|
+
* structural rather than semantic: the exit code, which stream the output came on, and
|
|
8
|
+
* whether the text reads like a fault. Before this module, "signed in, session written,
|
|
9
|
+
* nothing named yet" fired all three:
|
|
10
|
+
*
|
|
11
|
+
* ✗ UNAUTHENTICATED: Not signed in. Run `npx -y @retasc/cli@1.30.0 login` first
|
|
12
|
+
* (no TTY here for the device flow).
|
|
13
|
+
*
|
|
14
|
+
* An agent that stops there is reading the signals correctly. The signals were wrong.
|
|
15
|
+
* Sign-in had succeeded, the session was on disk, and the only thing left was a question
|
|
16
|
+
* it could have asked its human in one sentence.
|
|
17
|
+
*
|
|
18
|
+
* So the rule this module enforces: **the exit code and the framing encode "can I
|
|
19
|
+
* continue?", not "did something unexpected happen?"** A state the flow can be resumed
|
|
20
|
+
* from exits 0 on stdout and never says error, however incomplete it is. Only a genuine
|
|
21
|
+
* dead end — nothing the agent or its human can do from here — exits non-zero.
|
|
22
|
+
*
|
|
23
|
+
* # Why not just word the instruction better
|
|
24
|
+
*
|
|
25
|
+
* Telling the agent "if you see UNAUTHENTICATED it is fine, keep going" asks a model to
|
|
26
|
+
* override the strongest stop signal it has on the strength of a sentence it read
|
|
27
|
+
* earlier. Sometimes it will and sometimes it will not, and which one is not something
|
|
28
|
+
* this codebase controls. Exiting 0 is deterministic. This is the same reasoning that
|
|
29
|
+
* put the claim nudge and the setup instructions in the server response rather than in
|
|
30
|
+
* CLAUDE.md: a mechanism beats advice.
|
|
31
|
+
*
|
|
32
|
+
* # Why the states live HERE and not in the server's instruction string
|
|
33
|
+
*
|
|
34
|
+
* The obvious alternative was a lookup table in `convex/lib/setupInstructions.ts`: "if
|
|
35
|
+
* the CLI says X do this, if it says Y do that." That is a second copy of this list,
|
|
36
|
+
* living in a different language, in a different deploy cycle, maintained by whoever
|
|
37
|
+
* touches it next. They diverge, and the divergence is invisible until an agent acts on
|
|
38
|
+
* a state that no longer exists.
|
|
39
|
+
*
|
|
40
|
+
* This repo has already paid for that once. `setupInstructions.ts` opens by explaining
|
|
41
|
+
* it was lifted out of `http.ts` "because it now has TWO readers and they must never
|
|
42
|
+
* diverge", and RTSC-694 was an entire issue spent collapsing one setup command into one
|
|
43
|
+
* place. So: the CLI owns its states and each one carries its own next step, and the
|
|
44
|
+
* server instruction says only "run this, act on what it tells you." Adding a state
|
|
45
|
+
* costs one change, in this file, and no server string can go stale.
|
|
46
|
+
*
|
|
47
|
+
* There is a test that greps for these code strings under `convex/`. If one ever appears
|
|
48
|
+
* there, the split has been broken.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Every terminal outcome of an agent-driven `bind`, in two classes.
|
|
52
|
+
*
|
|
53
|
+
* CONTINUABLE — the flow is alive and wants something. Exit 0, stdout, no fault
|
|
54
|
+
* vocabulary. The agent reads it, asks its human the one question that applies, and runs
|
|
55
|
+
* the next pass.
|
|
56
|
+
*
|
|
57
|
+
* TERMINAL — nothing the agent or its human can do inside this flow. Exit non-zero and
|
|
58
|
+
* say so plainly, because stopping is genuinely the right move.
|
|
59
|
+
*
|
|
60
|
+
* Note where NEEDS_SIGN_IN sits. Reaching a command with no session, having not yet
|
|
61
|
+
* tried to get one, is CONTINUABLE: the answer is to sign in, not to give up. Only a
|
|
62
|
+
* sign-in that was attempted and failed is terminal. Collapsing those two was the
|
|
63
|
+
* original bug.
|
|
64
|
+
*/
|
|
65
|
+
export const CONTINUABLE_STATES = [
|
|
66
|
+
/** No session, and no attempt has been made yet. The browser door is available. */
|
|
67
|
+
"NEEDS_SIGN_IN",
|
|
68
|
+
/** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
|
|
69
|
+
"NEEDS_ORG",
|
|
70
|
+
/** Org settled. Zero projects, or more than one, so which project is unknown. */
|
|
71
|
+
"NEEDS_PROJECT",
|
|
72
|
+
/**
|
|
73
|
+
* Signed in, and holding an invitation. The choice is join-or-create and it is the
|
|
74
|
+
* human's to make.
|
|
75
|
+
*
|
|
76
|
+
* NOT called PENDING_INVITE, deliberately: `convex/setupStatus.ts` already has a state
|
|
77
|
+
* by that name and it means something else — a membership offer the server is
|
|
78
|
+
* surfacing, rather than the reason `bind` stopped. Two different things sharing one
|
|
79
|
+
* identifier across the boundary is precisely the confusion this file's split exists to
|
|
80
|
+
* prevent, so this one is named for the decision it is asking for.
|
|
81
|
+
*/
|
|
82
|
+
"NEEDS_JOIN_OR_CREATE",
|
|
83
|
+
/** Everything named and written. The folder is bound; the client must restart. */
|
|
84
|
+
"BOUND",
|
|
85
|
+
];
|
|
86
|
+
export const TERMINAL_STATES = [
|
|
87
|
+
/** Sign-in was attempted and did not complete: declined, timed out, or rejected. */
|
|
88
|
+
"SIGN_IN_FAILED",
|
|
89
|
+
/** No door exists: no browser reachable and no TTY. SSH and CI land here. */
|
|
90
|
+
"NO_SIGN_IN_DOOR",
|
|
91
|
+
/** This folder is already bound and the run was refused rather than silently re-pointed. */
|
|
92
|
+
"ALREADY_BOUND",
|
|
93
|
+
/** The credential was refused: revoked key, suspended member, deleted workspace. */
|
|
94
|
+
"REFUSED",
|
|
95
|
+
];
|
|
96
|
+
export function isContinuable(state) {
|
|
97
|
+
return CONTINUABLE_STATES.includes(state);
|
|
98
|
+
}
|
|
99
|
+
/** stdout, one line, no trailing prose. Never stderr: see the module header. */
|
|
100
|
+
export function emit(e) {
|
|
101
|
+
process.stdout.write(JSON.stringify(e) + "\n");
|
|
102
|
+
}
|