ramwisp 0.1.3 → 0.1.4
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/README.md +5 -5
- package/bin/wisp.js +16 -16
- package/package.json +2 -2
- package/src/attest.js +15 -15
- package/src/client.js +17 -17
- package/src/creds.js +11 -11
- package/src/mcp.js +38 -38
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# ramwisp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Claude Code and Codex subagents on their own cloud machines, with the RAM they need.
|
|
4
|
+
Every subagent runs inside an ephemeral AWS Nitro Enclave: before sending anything, this MCP verifies the
|
|
5
|
+
hardware attestation (AWS Nitro Enclaves Root G1 + published PCR0) and encrypts the task, the project and the
|
|
6
|
+
credential for that enclave only.
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
claude mcp add --scope user ramwisp -- npx -y ramwisp@latest
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
On first use it opens your browser so you can sign in. More at https://ramwisp.duckdns.org
|
package/bin/wisp.js
CHANGED
|
@@ -8,38 +8,38 @@ const [cmd, ...rest] = process.argv.slice(2);
|
|
|
8
8
|
const flag = (name, d) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : d; };
|
|
9
9
|
const out = (x) => console.log(JSON.stringify(x, null, 2));
|
|
10
10
|
|
|
11
|
-
const HELP = `
|
|
11
|
+
const HELP = `ramwisp — subagents with on-demand RAM (${API})
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
ramwisp login connect this machine to your account (browser)
|
|
14
|
+
ramwisp logout
|
|
15
|
+
ramwisp spawn "task" [--ram 2] [--engine claude|codex] [--model M] [--auth auto|login|key] [--workspace DIR] [--wait]
|
|
16
|
+
ramwisp wait ID | result ID | kill ID
|
|
17
|
+
ramwisp ls
|
|
18
|
+
ramwisp setup how to add the MCP to Claude Code / Codex
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
With no arguments it runs the MCP server (stdio).`;
|
|
21
21
|
|
|
22
22
|
async function main() {
|
|
23
23
|
switch (cmd) {
|
|
24
24
|
case undefined: case "mcp": return serve();
|
|
25
25
|
case "login": {
|
|
26
|
-
if (getToken()) return console.log(`
|
|
26
|
+
if (getToken()) return console.log(`already connected to ${API} (ramwisp logout to switch)`);
|
|
27
27
|
const l = await startLogin();
|
|
28
|
-
console.log(`${l.opened ? "
|
|
28
|
+
console.log(`${l.opened ? "Opened your browser. If it didn't open, visit" : "Open"}: ${l.verification_uri_complete}\nCode: ${l.user_code}`);
|
|
29
29
|
await l.done;
|
|
30
|
-
return console.log("
|
|
30
|
+
return console.log("connected ✓");
|
|
31
31
|
}
|
|
32
|
-
case "logout": logout(); return console.log("
|
|
32
|
+
case "logout": logout(); return console.log("disconnected");
|
|
33
33
|
case "spawn": {
|
|
34
34
|
const r = await spawnAgent({ mission: rest[0], ram_gb: Number(flag("ram", 2)), engine: flag("engine", "claude"),
|
|
35
35
|
model: flag("model"), auth: flag("auth", "auto"), timeout_s: Number(flag("timeout", 1800)), label: flag("label"), workspace: flag("workspace") });
|
|
36
36
|
out(r);
|
|
37
|
-
console.error("
|
|
37
|
+
console.error("waiting for the machine and its attestation to send the sealed task…");
|
|
38
38
|
await sealed(r.id);
|
|
39
39
|
const now = await result(r.id);
|
|
40
|
-
if (now.
|
|
40
|
+
if (now.error || ["failed", "killed", "expired"].includes(now.status)) { out(now); process.exit(1); }
|
|
41
41
|
if (rest.includes("--wait")) return out(await waitAgent(r.id, 3600));
|
|
42
|
-
return console.error(`
|
|
42
|
+
return console.error(`task delivered. Collect it with: ramwisp wait ${r.id}`);
|
|
43
43
|
}
|
|
44
44
|
case "wait": return out(await waitAgent(rest[0], Number(flag("max", 3600))));
|
|
45
45
|
case "result": return out(await result(rest[0]));
|
|
@@ -52,4 +52,4 @@ async function main() {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
main().catch((e) => { console.error("
|
|
55
|
+
main().catch((e) => { console.error("error:", e.message); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ramwisp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ramwisp:
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "ramwisp: Claude Code and Codex subagents on their own cloud machines (AWS Nitro Enclaves), with your own subscription. MCP + CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ramwisp": "bin/wisp.js",
|
package/src/attest.js
CHANGED
|
@@ -22,44 +22,44 @@ export function allowedPcr0() {
|
|
|
22
22
|
*/
|
|
23
23
|
export function verifyAttestation(docB64, expectedNonce, { devRootPem } = {}) {
|
|
24
24
|
const doc = decode(Buffer.from(docB64, "base64"));
|
|
25
|
-
if (!Array.isArray(doc) || doc.length !== 4) throw new Error("
|
|
25
|
+
if (!Array.isArray(doc) || doc.length !== 4) throw new Error("attestation: not a COSE_Sign1");
|
|
26
26
|
const [protectedBytes, , payloadBytes, signature] = doc;
|
|
27
27
|
const prot = decode(protectedBytes);
|
|
28
|
-
if (!(prot instanceof Map) || prot.get(1) !== -35) throw new Error("
|
|
28
|
+
if (!(prot instanceof Map) || prot.get(1) !== -35) throw new Error("attestation: algorithm is not ES384");
|
|
29
29
|
const p = decode(payloadBytes);
|
|
30
|
-
const get = (k) => { const v = p.get(k); if (v === undefined || v === null) throw new Error(`
|
|
30
|
+
const get = (k) => { const v = p.get(k); if (v === undefined || v === null) throw new Error(`attestation: missing ${k}`); return v; };
|
|
31
31
|
|
|
32
32
|
// cadeia: cabundle[0] é a raiz, depois intermediárias, por fim o certificado da enclave
|
|
33
33
|
const bundle = get("cabundle").map((der) => new X509Certificate(der));
|
|
34
34
|
const leaf = new X509Certificate(get("certificate"));
|
|
35
35
|
const dev = !!devRootPem;
|
|
36
36
|
const root = dev ? new X509Certificate(devRootPem) : NITRO_ROOT;
|
|
37
|
-
if (!bundle.length || !bundle[0].raw.equals(root.raw)) throw new Error("
|
|
37
|
+
if (!bundle.length || !bundle[0].raw.equals(root.raw)) throw new Error("attestation: root is not AWS Nitro Enclaves");
|
|
38
38
|
const chain = [...bundle, leaf];
|
|
39
39
|
const at = new Date();
|
|
40
40
|
for (let i = 0; i < chain.length; i++) {
|
|
41
41
|
const cert = chain[i];
|
|
42
42
|
const issuer = i === 0 ? root : chain[i - 1];
|
|
43
|
-
if (!cert.verify(issuer.publicKey)) throw new Error(`
|
|
44
|
-
if (at < new Date(cert.validFrom) || at > new Date(cert.validTo)) throw new Error(`
|
|
45
|
-
if (i < chain.length - 1 && !cert.ca) throw new Error(`
|
|
43
|
+
if (!cert.verify(issuer.publicKey)) throw new Error(`attestation: certificate ${i} signature invalid`);
|
|
44
|
+
if (at < new Date(cert.validFrom) || at > new Date(cert.validTo)) throw new Error(`attestation: certificate ${i} outside its validity`);
|
|
45
|
+
if (i < chain.length - 1 && !cert.ca) throw new Error(`attestation: certificate ${i} is not a CA`);
|
|
46
46
|
}
|
|
47
47
|
const ok = verify("sha384", encodeSigStructure(protectedBytes, payloadBytes),
|
|
48
48
|
{ key: leaf.publicKey, dsaEncoding: "ieee-p1363" }, signature);
|
|
49
|
-
if (!ok) throw new Error("
|
|
49
|
+
if (!ok) throw new Error("attestation: invalid COSE signature");
|
|
50
50
|
|
|
51
|
-
if (get("digest") !== "SHA384") throw new Error("
|
|
51
|
+
if (get("digest") !== "SHA384") throw new Error("attestation: unexpected digest");
|
|
52
52
|
const ts = get("timestamp");
|
|
53
|
-
if (Math.abs(Date.now() - ts) > MAX_AGE_MS) throw new Error("
|
|
54
|
-
if (!Buffer.from(get("nonce")).equals(expectedNonce)) throw new Error("
|
|
53
|
+
if (Math.abs(Date.now() - ts) > MAX_AGE_MS) throw new Error("attestation: too old or from the future");
|
|
54
|
+
if (!Buffer.from(get("nonce")).equals(expectedNonce)) throw new Error("attestation: nonce mismatch (replay?)");
|
|
55
55
|
const ud = p.get("user_data");
|
|
56
|
-
if (!ud || Buffer.from(ud).toString() !== "wisp-v1") throw new Error("
|
|
56
|
+
if (!ud || Buffer.from(ud).toString() !== "wisp-v1") throw new Error("attestation: unknown protocol");
|
|
57
57
|
const enclavePub = Buffer.from(get("public_key"));
|
|
58
|
-
if (enclavePub.length !== 32) throw new Error("
|
|
58
|
+
if (enclavePub.length !== 32) throw new Error("attestation: invalid public key");
|
|
59
59
|
|
|
60
60
|
const pcr0 = Buffer.from(get("pcrs").get(0)).toString("hex");
|
|
61
|
-
if (/^0+$/.test(pcr0)) throw new Error("
|
|
61
|
+
if (/^0+$/.test(pcr0)) throw new Error("attestation: enclave in debug mode (zero PCR0) — refused");
|
|
62
62
|
const allowed = dev ? [DEV_PCR0] : allowedPcr0();
|
|
63
|
-
if (!allowed.includes(pcr0)) throw new Error(`
|
|
63
|
+
if (!allowed.includes(pcr0)) throw new Error(`attestation: unrecognized image (PCR0 ${pcr0.slice(0, 16)}…)`);
|
|
64
64
|
return { enclavePub, pcr0, moduleId: get("module_id"), timestamp: ts, dev };
|
|
65
65
|
}
|
package/src/client.js
CHANGED
|
@@ -21,7 +21,7 @@ const SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "venv", "__pycache__
|
|
|
21
21
|
/** Copia do projeto: no git, o que ele rastreia + arquivos novos não ignorados (nunca o que está no .gitignore). */
|
|
22
22
|
export function packWorkspace(dir) {
|
|
23
23
|
const root = resolve(dir);
|
|
24
|
-
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`workspace
|
|
24
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`workspace is not a directory: ${root}`);
|
|
25
25
|
let files;
|
|
26
26
|
const git = spawnSync("git", ["-C", root, "ls-files", "-z", "-co", "--exclude-standard"], { maxBuffer: 256 * 1024 * 1024 });
|
|
27
27
|
if (git.status === 0) {
|
|
@@ -41,8 +41,8 @@ export function packWorkspace(dir) {
|
|
|
41
41
|
const tar = spawnSync("tar", ["-czf", "-", "-C", root, "--null", "-T", "-"], { input: files.join("\0"), maxBuffer: 256 * 1024 * 1024 });
|
|
42
42
|
if (tar.status !== 0) throw new Error(`tar falhou: ${tar.stderr.toString().slice(0, 300)}`);
|
|
43
43
|
if (tar.stdout.length > MAX_WORKSPACE) {
|
|
44
|
-
throw new Error(`
|
|
45
|
-
"
|
|
44
|
+
throw new Error(`project too large (${(tar.stdout.length / 1048576).toFixed(1)} MB compressed, max 15 MB): ` +
|
|
45
|
+
"point workspace at a subdirectory or ignore heavy files in .gitignore");
|
|
46
46
|
}
|
|
47
47
|
return { root, tgz: tar.stdout, files: files.length };
|
|
48
48
|
}
|
|
@@ -56,7 +56,7 @@ export async function spawnAgent(o) {
|
|
|
56
56
|
let note;
|
|
57
57
|
if (cred.left && cred.left - 300 < timeout) {
|
|
58
58
|
timeout = Math.max(60, Math.floor(cred.left - 300));
|
|
59
|
-
note = `timeout
|
|
59
|
+
note = `timeout lowered to ${timeout}s (local login validity)`;
|
|
60
60
|
}
|
|
61
61
|
const ws = o.workspace ? packWorkspace(o.workspace) : null;
|
|
62
62
|
const priv = newClientKey();
|
|
@@ -72,7 +72,7 @@ export async function spawnAgent(o) {
|
|
|
72
72
|
p.catch(() => {});
|
|
73
73
|
return { id: job.id, status: job.status, engine, ram_gb: job.ram_gb, instance_type: job.instance_type,
|
|
74
74
|
credential: cred.source, reserved_usd: +(job.hold_cents / 100).toFixed(4),
|
|
75
|
-
...(ws ? { workspace: `${ws.root} (${ws.files}
|
|
75
|
+
...(ws ? { workspace: `${ws.root} (${ws.files} files, ${(ws.tgz.length / 1024).toFixed(0)} KB, encrypted)` } : {}),
|
|
76
76
|
...(note ? { note } : {}) };
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -98,7 +98,7 @@ async function sealWhenReady(id, priv, nonce, payload) {
|
|
|
98
98
|
}
|
|
99
99
|
await new Promise((r) => setTimeout(r, 2000));
|
|
100
100
|
}
|
|
101
|
-
throw new Error("
|
|
101
|
+
throw new Error("the machine was not ready within 20 min");
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** Espera a selagem terminar (útil para o CLI, que sai logo depois). */
|
|
@@ -130,31 +130,31 @@ function parseOutput(stdout) {
|
|
|
130
130
|
export async function result(id) {
|
|
131
131
|
const j = await call("GET", `/api/jobs/${id}`);
|
|
132
132
|
const meta = { id, status: j.status, ram_gb: j.ram_gb, peak_mem_mib: j.peak_mem_mib, cost_usd: j.cost_cents != null ? +(j.cost_cents / 100).toFixed(4) : null };
|
|
133
|
-
if (sealErrors.has(id)) return { ...meta, status: "failed",
|
|
133
|
+
if (sealErrors.has(id)) return { ...meta, status: "failed", error: `refused for security: ${sealErrors.get(id)}` };
|
|
134
134
|
if (!FINAL.includes(j.status)) return { ...meta, mem_used_mib: j.mem_used_mib };
|
|
135
135
|
if (j.status !== "done" || !j.output) {
|
|
136
136
|
rmSync(keyFile(id), { force: true });
|
|
137
|
-
return { ...meta,
|
|
137
|
+
return { ...meta, error: j.error ?? (j.collected_at ? "result was already collected" : j.status) };
|
|
138
138
|
}
|
|
139
139
|
const f = keyFile(id);
|
|
140
|
-
if (!existsSync(f)) return { ...meta,
|
|
140
|
+
if (!existsSync(f)) return { ...meta, error: "the key to open this result is not on this machine" };
|
|
141
141
|
const k = JSON.parse(readFileSync(f, "utf8"));
|
|
142
142
|
const out = JSON.parse(openOutput(importKey(k.priv), Buffer.from(k.enclave_pub, "base64"), Buffer.from(k.nonce, "base64"), j.output).toString());
|
|
143
143
|
await call("POST", `/api/jobs/${id}/collected`).catch(() => {});
|
|
144
144
|
rmSync(f, { force: true });
|
|
145
145
|
const parsed = parseOutput(out.stdout ?? "");
|
|
146
146
|
const res = { ...parsed, ...meta, exit_code: out.exit_code, duration_s: out.duration_s };
|
|
147
|
-
if (out.exit_code === 124) res.
|
|
147
|
+
if (out.exit_code === 124) res.error = "timeout";
|
|
148
148
|
if (out.patch) {
|
|
149
149
|
const f = join(ensureDir("patches"), `${id}.patch`);
|
|
150
150
|
writeSecret(f, out.patch);
|
|
151
151
|
res.patch_file = f;
|
|
152
152
|
res.patch_stat = out.patch_stat;
|
|
153
|
-
res.
|
|
153
|
+
res.apply = k.workspace ? `git -C ${JSON.stringify(k.workspace)} apply ${JSON.stringify(f)}` : `git apply ${JSON.stringify(f)}`;
|
|
154
154
|
} else if (out.patch === "") {
|
|
155
|
-
res.patch_stat = "
|
|
155
|
+
res.patch_stat = "no changes to the project";
|
|
156
156
|
} else if (out.patch_error) {
|
|
157
|
-
res.
|
|
157
|
+
res.patch_error = out.patch_error;
|
|
158
158
|
res.patch_stat = out.patch_stat;
|
|
159
159
|
}
|
|
160
160
|
if (out.exit_code !== 0 && out.stderr_tail) res.stderr_tail = out.stderr_tail.slice(-1500);
|
|
@@ -165,7 +165,7 @@ export async function waitAgent(id, maxWaitS = 900) {
|
|
|
165
165
|
const until = Date.now() + maxWaitS * 1000;
|
|
166
166
|
for (;;) {
|
|
167
167
|
const r = await result(id);
|
|
168
|
-
if (FINAL.includes(r.status) || r.
|
|
168
|
+
if (FINAL.includes(r.status) || r.error || Date.now() > until) return r;
|
|
169
169
|
await new Promise((res) => setTimeout(res, 3000));
|
|
170
170
|
}
|
|
171
171
|
}
|
|
@@ -179,9 +179,9 @@ export async function killAgent(id) {
|
|
|
179
179
|
export async function listAgents() {
|
|
180
180
|
const [me, jobs] = await Promise.all([call("GET", "/api/me"), call("GET", "/api/jobs?limit=20")]);
|
|
181
181
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
account: me.email, balance_usd: +(me.credit_cents / 100).toFixed(2),
|
|
183
|
+
ram_available_gb: me.tiers.filter((t) => t.available).map((t) => t.ram_gb),
|
|
184
|
+
agents: jobs.filter((j) => !FINAL.includes(j.status) || (j.status === "done" && !j.collected_at)).map((j) => ({
|
|
185
185
|
id: j.id, status: j.status, engine: j.engine, ram_gb: j.ram_gb, mem_used_mib: j.mem_used_mib, label: j.label })),
|
|
186
186
|
};
|
|
187
187
|
}
|
package/src/creds.js
CHANGED
|
@@ -24,8 +24,8 @@ function claudeLogin() {
|
|
|
24
24
|
|
|
25
25
|
function claudeCredential(mode, needS) {
|
|
26
26
|
const key = process.env.WISP_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
|
|
27
|
-
if (mode !== "login" && key) return { kind: "anthropic_key", value: key, left: null, source: "
|
|
28
|
-
if (mode === "key") throw new Error("
|
|
27
|
+
if (mode !== "login" && key) return { kind: "anthropic_key", value: key, left: null, source: "API key" };
|
|
28
|
+
if (mode === "key") throw new Error("key mode: set ANTHROPIC_API_KEY (or WISP_ANTHROPIC_API_KEY) in the MCP env");
|
|
29
29
|
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { kind: "claude_oauth", value: process.env.CLAUDE_CODE_OAUTH_TOKEN, left: null, source: "CLAUDE_CODE_OAUTH_TOKEN" };
|
|
30
30
|
let l = claudeLogin();
|
|
31
31
|
if (l && l.exp - Date.now() / 1000 < Math.max(MIN_LEFT_S, needS)) {
|
|
@@ -33,30 +33,30 @@ function claudeCredential(mode, needS) {
|
|
|
33
33
|
spawnSync("claude", ["-p", "ok", "--max-turns", "1"], { stdio: "ignore", timeout: 120_000 });
|
|
34
34
|
l = claudeLogin();
|
|
35
35
|
}
|
|
36
|
-
if (!l) throw new Error("
|
|
36
|
+
if (!l) throw new Error("no Claude Code login on this machine: run `claude` and sign in, or use a key (ANTHROPIC_API_KEY)");
|
|
37
37
|
const left = l.exp - Date.now() / 1000;
|
|
38
|
-
if (left < MIN_LEFT_S) throw new Error("
|
|
39
|
-
return { kind: "claude_oauth", value: l.token, left, source: "
|
|
38
|
+
if (left < MIN_LEFT_S) throw new Error("the local Claude login did not refresh; open Claude Code once and try again");
|
|
39
|
+
return { kind: "claude_oauth", value: l.token, left, source: "Claude Code login" };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const jwtExp = (t) => JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()).exp;
|
|
43
43
|
|
|
44
44
|
function codexCredential(mode) {
|
|
45
45
|
const key = process.env.WISP_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
|
|
46
|
-
if (mode !== "login" && key) return { kind: "openai_key", value: key, left: null, source: "
|
|
46
|
+
if (mode !== "login" && key) return { kind: "openai_key", value: key, left: null, source: "API key" };
|
|
47
47
|
const path = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
|
|
48
48
|
let d;
|
|
49
49
|
try { d = JSON.parse(readFileSync(path, "utf8")); } catch {
|
|
50
|
-
throw new Error(mode === "key" ? "
|
|
50
|
+
throw new Error(mode === "key" ? "key mode: set OPENAI_API_KEY in the MCP env" : "no Codex login on this machine: run `codex login`");
|
|
51
51
|
}
|
|
52
|
-
if (mode !== "login" && d.OPENAI_API_KEY) return { kind: "openai_key", value: d.OPENAI_API_KEY, left: null, source: "
|
|
53
|
-
if (mode === "key") throw new Error("
|
|
52
|
+
if (mode !== "login" && d.OPENAI_API_KEY) return { kind: "openai_key", value: d.OPENAI_API_KEY, left: null, source: "Codex API key" };
|
|
53
|
+
if (mode === "key") throw new Error("key mode: set OPENAI_API_KEY in the MCP env");
|
|
54
54
|
const t = d.tokens ?? {};
|
|
55
55
|
const left = jwtExp(t.access_token) - Date.now() / 1000;
|
|
56
|
-
if (left < MIN_LEFT_S) throw new Error("
|
|
56
|
+
if (left < MIN_LEFT_S) throw new Error("the Codex login expired; open Codex once on this machine");
|
|
57
57
|
const slim = { auth_mode: d.auth_mode ?? "chatgpt", OPENAI_API_KEY: null, last_refresh: new Date().toISOString(),
|
|
58
58
|
tokens: { id_token: t.id_token, access_token: t.access_token, account_id: t.account_id, refresh_token: "" } };
|
|
59
|
-
return { kind: "codex_auth", value: JSON.stringify(slim), left, source: "
|
|
59
|
+
return { kind: "codex_auth", value: JSON.stringify(slim), left, source: "ChatGPT/Codex login" };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** mode: "auto" (chave se houver, senão login), "key" ou "login". */
|
package/src/mcp.js
CHANGED
|
@@ -4,68 +4,68 @@ import { API, getToken, setClient, startLogin } from "./account.js";
|
|
|
4
4
|
import { LoginRequired, killAgent, listAgents, result, spawnAgent, waitAgent } from "./client.js";
|
|
5
5
|
|
|
6
6
|
const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
7
|
-
const VERSION = "0.1.
|
|
7
|
+
const VERSION = "0.1.4";
|
|
8
8
|
|
|
9
|
-
const INSTRUCTIONS = `
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const INSTRUCTIONS = `ramwisp runs Claude Code or Codex subagents on ephemeral cloud machines with the RAM you ask for,
|
|
10
|
+
without loading this machine. Each subagent starts inside an isolated enclave (AWS Nitro): before sending anything,
|
|
11
|
+
this MCP cryptographically verifies the enclave image and encrypts the task and the user's login for that enclave only.
|
|
12
|
+
Not even the ramwisp operator can read the task, the credential or the result. The machine is destroyed when it finishes.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
When to use: the user asks for remote subagents / "run it on ramwisp" / "spin up N subagents", or there are
|
|
15
|
+
independent, heavy tasks (builds, test suites, long research) that can run in parallel elsewhere.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- A
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
17
|
+
How to use it well:
|
|
18
|
+
- Parallel: call spawn_agent for every task first, then wait_agent for each id.
|
|
19
|
+
- To work on the user's code, pass workspace (e.g. the project directory): an encrypted copy is sent,
|
|
20
|
+
the subagent works on it and the changes come back as a patch (apply it with the "apply" command after reviewing).
|
|
21
|
+
Without workspace the machine starts empty: put all context in the mission. It has internet (HTTPS) but no git/SSH access of the user.
|
|
22
|
+
- A machine takes ~1-3 min to start; wait_agent waits up to 15 min per call (call it again if it returns running).
|
|
23
|
+
- Always collect with wait_agent or agent_result: the answer can only be decrypted on this machine.
|
|
24
|
+
- Each subagent uses ramwisp credit (the machine) and the user's subscription/key (the model). Don't launch dozens.
|
|
25
|
+
- If a response says the user needs to sign in, show them the link.`;
|
|
26
26
|
|
|
27
27
|
const TOOLS = [
|
|
28
|
-
{ name: "spawn_agent", description: "
|
|
28
|
+
{ name: "spawn_agent", description: "Launch an ephemeral subagent on a machine with the requested RAM and return its id right away.",
|
|
29
29
|
inputSchema: { type: "object", required: ["mission"], properties: {
|
|
30
|
-
mission: { type: "string", description: "
|
|
30
|
+
mission: { type: "string", description: "Complete, self-contained task with all the context it needs." },
|
|
31
31
|
engine: { type: "string", enum: ["claude", "codex"], default: "claude" },
|
|
32
|
-
model: { type: "string", description: "
|
|
33
|
-
ram_gb: { type: "integer", enum: [2, 4, 8, 16, 24], default: 2, description: "RAM
|
|
34
|
-
max_turns: { type: "integer", default: 20, description: "
|
|
32
|
+
model: { type: "string", description: "Model for the engine. Omit for its default." },
|
|
33
|
+
ram_gb: { type: "integer", enum: [2, 4, 8, 16, 24], default: 2, description: "Subagent RAM." },
|
|
34
|
+
max_turns: { type: "integer", default: 20, description: "Claude only." },
|
|
35
35
|
timeout_s: { type: "integer", default: 1800, minimum: 60, maximum: 7200 },
|
|
36
36
|
auth: { type: "string", enum: ["auto", "login", "key"], default: "auto",
|
|
37
|
-
description: "login =
|
|
38
|
-
workspace: { type: "string", description: "
|
|
39
|
-
label: { type: "string", description: "
|
|
40
|
-
{ name: "wait_agent", description: "
|
|
37
|
+
description: "login = the user's subscription on this machine; key = API key from env; auto = key if present." },
|
|
38
|
+
workspace: { type: "string", description: "Path to a directory/repo ON THIS MACHINE to send along. An encrypted copy is sent (in git: tracked files + new non-ignored files; never anything in .gitignore). The subagent works in ~/work and changes come back as a patch (patch_file + apply command). Max 15 MB compressed." },
|
|
39
|
+
label: { type: "string", description: "Short label VISIBLE in the dashboard (don't put anything sensitive)." } } } },
|
|
40
|
+
{ name: "wait_agent", description: "Wait for the subagent to finish and return the result (field result = the answer).",
|
|
41
41
|
inputSchema: { type: "object", required: ["id"], properties: {
|
|
42
42
|
id: { type: "string" }, max_wait_s: { type: "integer", default: 900, maximum: 1800 } } } },
|
|
43
|
-
{ name: "agent_result", description: "
|
|
43
|
+
{ name: "agent_result", description: "Result without waiting: returns the answer or the current status (running, RAM in use).",
|
|
44
44
|
inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } } },
|
|
45
|
-
{ name: "kill_agent", description: "
|
|
45
|
+
{ name: "kill_agent", description: "Kill the subagent and destroy its machine immediately.",
|
|
46
46
|
inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } } },
|
|
47
|
-
{ name: "list_agents", description: "
|
|
47
|
+
{ name: "list_agents", description: "Balance, available RAM sizes and subagents that are running or have results to collect.",
|
|
48
48
|
inputSchema: { type: "object", properties: {} } },
|
|
49
|
-
{ name: "wisp_login", description: "
|
|
49
|
+
{ name: "wisp_login", description: "Connect this MCP to the user's ramwisp account (opens the browser).",
|
|
50
50
|
inputSchema: { type: "object", properties: {} } },
|
|
51
51
|
];
|
|
52
52
|
|
|
53
53
|
async function loginMessage() {
|
|
54
54
|
const l = await startLogin();
|
|
55
|
-
//
|
|
55
|
+
// with a browser open here, wait a bit for approval and continue on its own
|
|
56
56
|
if (l.opened) {
|
|
57
57
|
const ok = await Promise.race([l.done.then(() => true).catch(() => false), new Promise((r) => setTimeout(() => r(false), 120_000))]);
|
|
58
58
|
if (ok) return null;
|
|
59
59
|
}
|
|
60
|
-
return `
|
|
61
|
-
`
|
|
60
|
+
return `To use ramwisp, connect your account: open ${l.verification_uri_complete} and confirm the code ${l.user_code}.\n` +
|
|
61
|
+
`No account yet? Create one there, with free credit. Then repeat the request.`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
async function runTool(name, a) {
|
|
65
65
|
if (name === "wisp_login") {
|
|
66
|
-
if (getToken()) return { ok: true, msg: `
|
|
66
|
+
if (getToken()) return { ok: true, msg: `already connected to ${API}` };
|
|
67
67
|
const msg = await loginMessage();
|
|
68
|
-
return msg ? {
|
|
68
|
+
return msg ? { login_required: msg } : { ok: true, msg: "account connected" };
|
|
69
69
|
}
|
|
70
70
|
const attempt = () => {
|
|
71
71
|
switch (name) {
|
|
@@ -74,7 +74,7 @@ async function runTool(name, a) {
|
|
|
74
74
|
case "agent_result": return result(a.id);
|
|
75
75
|
case "kill_agent": return killAgent(a.id);
|
|
76
76
|
case "list_agents": return listAgents();
|
|
77
|
-
default: throw new Error(`
|
|
77
|
+
default: throw new Error(`unknown tool: ${name}`);
|
|
78
78
|
}
|
|
79
79
|
};
|
|
80
80
|
try {
|
|
@@ -83,7 +83,7 @@ async function runTool(name, a) {
|
|
|
83
83
|
} catch (e) {
|
|
84
84
|
if (!(e instanceof LoginRequired) && e.status !== 401) throw e;
|
|
85
85
|
const msg = await loginMessage();
|
|
86
|
-
if (msg) return {
|
|
86
|
+
if (msg) return { login_required: msg };
|
|
87
87
|
return await attempt();
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -111,10 +111,10 @@ export function serve() {
|
|
|
111
111
|
const out = await runTool(params.name, params.arguments ?? {});
|
|
112
112
|
res = { content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 1) }] };
|
|
113
113
|
} catch (e) {
|
|
114
|
-
res = { content: [{ type: "text", text: `
|
|
114
|
+
res = { content: [{ type: "text", text: `error: ${e.message}` }], isError: true };
|
|
115
115
|
}
|
|
116
116
|
} else {
|
|
117
|
-
return send({ jsonrpc: "2.0", id, error: { code: -32601, message: `
|
|
117
|
+
return send({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method: ${method}` } });
|
|
118
118
|
}
|
|
119
119
|
send({ jsonrpc: "2.0", id, result: res });
|
|
120
120
|
} catch (e) {
|