clauderipple 0.9.4 → 0.10.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 +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A translated reasoning model's own turn, carried through Claude Code's transcript.
|
|
2
|
+
//
|
|
3
|
+
// The Responses wire hands its model an opaque reasoning item — `{type:"reasoning", id, summary,
|
|
4
|
+
// encrypted_content}` — and expects it back, byte for byte, in the position it came from, beside the
|
|
5
|
+
// function calls of that same turn. That is not an optimisation: measured 2026-10-14 with Muse Spark
|
|
6
|
+
// 1.3 at xhigh on a real tool loop, runs whose previous reasoning was not returned spent
|
|
7
|
+
// 9.5k–24.5k reasoning tokens each (2,197 per step past 80k context) and one of three ran out of
|
|
8
|
+
// context without ever answering, where the same loop with the items returned spent 3.2k–4.3k per
|
|
9
|
+
// run (725 per step past 80k), answered in all three, and read 94% prompt cache. Dropping the reasoning is why a routed worker "thinks from
|
|
10
|
+
// scratch on every tool call" and then dies. DeepSeek's own Chat API states the same rule for
|
|
11
|
+
// `reasoning_content`: with `tools` present it "must be fully passed back to the API in all
|
|
12
|
+
// subsequent requests", even for turns that made no tool call, and the API answers a 400 otherwise
|
|
13
|
+
// (https://api-docs.deepseek.com/guides/thinking_mode/, read 2026-10-14).
|
|
14
|
+
//
|
|
15
|
+
// The router keeps no state of its own here. Claude Code resends the whole conversation every turn,
|
|
16
|
+
// so the transcript is the storage. The carrier is a `thinking` block's `signature`, which is a
|
|
17
|
+
// free-form string on the wire and is replayed with the block [measured 2026-10-14 against a fake
|
|
18
|
+
// upstream: a thinking block carrying our signature comes back on the next request of a tool loop
|
|
19
|
+
// with the signature byte-identical, and an *empty* thinking text is kept too — which is what a
|
|
20
|
+
// Responses reasoning item with no summary looks like]. Anthropic's own signature is opaque base64
|
|
21
|
+
// this router cannot forge; ours is a small envelope that names the wire, the provider, the model
|
|
22
|
+
// and the vendor's own reasoning, and it is decoded and dropped by `thinking.ts` before a request
|
|
23
|
+
// reaches Anthropic.
|
|
24
|
+
//
|
|
25
|
+
// opencodex (github.com/lidge-jun/opencodex, devlog/_fin/260711_claude_inbound/003_evidence.md
|
|
26
|
+
// lines 93-118) reached the same conclusion and the same carrier — a signature on a thinking block,
|
|
27
|
+
// read back out of the client's own conversation — and we rely on that as a behavioral
|
|
28
|
+
// specification only; no code was copied.
|
|
29
|
+
//
|
|
30
|
+
// `encrypted_content` is the vendor's own sealed blob. It goes in the envelope untouched and comes
|
|
31
|
+
// back untouched: re-encoding or re-signing it would be the "re-signed reasoning" mistake that cost
|
|
32
|
+
// an earlier translator 12–20% cache hit (ARCHITECTURE §4, "Cache-safety decisions").
|
|
33
|
+
//
|
|
34
|
+
// `include:["reasoning.encrypted_content"]` is asked for on every turn where the vendor is known to
|
|
35
|
+
// take it — the ChatGPT backend, as Codex asks, and a preset marked `sealedReasoning` (presets.ts),
|
|
36
|
+
// as OpenCode's own client asks. A `store: false` backend that is not asked hands back nothing, so
|
|
37
|
+
// waiting until a conversation already carried reasoning would never start one. For any other
|
|
38
|
+
// Responses vendor it is asked for only once the conversation already carries that vendor's
|
|
39
|
+
// reasoning, so a vendor never measured keeps the request it had. Measured 2026-10-14 against
|
|
40
|
+
// OpenCode Go (`muse-spark-1.3-contributor`, one tool-bearing turn): the sealed item came back with
|
|
41
|
+
// `include` and without it, both 1426 bytes, and replaying it in place answered 200. The same vendor
|
|
42
|
+
// also accepted a tampered blob and a turn with the item dropped entirely (both 200), so the 4xx
|
|
43
|
+
// retry below is a safety net for a vendor or an account that is stricter.
|
|
44
|
+
/** Names an envelope as ours. Anthropic's own signatures never start with it. */
|
|
45
|
+
export const ENVELOPE_PREFIX = "clauderipple.v1:";
|
|
46
|
+
/** Thrown when a signature looks like ours and does not decode. */
|
|
47
|
+
export class UndecodableEnvelope extends Error {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Our marker on a block, without decoding it: the cheap test `thinking.ts` and the request builders
|
|
51
|
+
* need before doing any work. Anthropic's own signatures are base64 and never carry this prefix.
|
|
52
|
+
*/
|
|
53
|
+
export function isOurSignature(signature) {
|
|
54
|
+
return typeof signature === "string" && signature.startsWith(ENVELOPE_PREFIX);
|
|
55
|
+
}
|
|
56
|
+
/** The `signature` of a block of ours: the envelope, base64url so it survives any signature field. */
|
|
57
|
+
export function encodeEnvelope(envelope) {
|
|
58
|
+
return ENVELOPE_PREFIX + Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url");
|
|
59
|
+
}
|
|
60
|
+
function envelope(value) {
|
|
61
|
+
if (!value || typeof value !== "object")
|
|
62
|
+
return null;
|
|
63
|
+
const e = value;
|
|
64
|
+
if (typeof e.p !== "string" || typeof e.m !== "string")
|
|
65
|
+
return null;
|
|
66
|
+
if (e.w === "chat")
|
|
67
|
+
return typeof e.f === "string" && e.f.length > 0 ? { w: "chat", p: e.p, m: e.m, f: e.f } : null;
|
|
68
|
+
if (e.w === "responses")
|
|
69
|
+
return e.r && typeof e.r === "object" ? { w: "responses", p: e.p, m: e.m, r: e.r } : null;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The envelope on a signature, or null when it is not ours. A signature that carries our prefix and
|
|
74
|
+
* does not decode is a broken carrier, not a foreign one, so it throws rather than passing silently
|
|
75
|
+
* as someone else's — the caller logs it and moves on.
|
|
76
|
+
*/
|
|
77
|
+
export function decodeEnvelope(signature) {
|
|
78
|
+
if (!isOurSignature(signature))
|
|
79
|
+
return null;
|
|
80
|
+
const text = signature.slice(ENVELOPE_PREFIX.length);
|
|
81
|
+
// Guard the decode: a signature is attacker-visible text and a gigabyte of base64 must not become
|
|
82
|
+
// a gigabyte of JSON. A sealed reasoning blob runs a few KB.
|
|
83
|
+
if (text.length > 512 * 1024)
|
|
84
|
+
throw new UndecodableEnvelope("envelope too large");
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(Buffer.from(text, "base64url").toString("utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new UndecodableEnvelope("envelope did not decode");
|
|
91
|
+
}
|
|
92
|
+
const decoded = envelope(parsed);
|
|
93
|
+
if (!decoded)
|
|
94
|
+
throw new UndecodableEnvelope("envelope was not a recognizable shape");
|
|
95
|
+
return decoded;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether a stored item is worth replaying at all. A reasoning item with no `encrypted_content`
|
|
99
|
+
* carries nothing the backend can resume — the summary is text it thinks in, not state — so it is
|
|
100
|
+
* not carried and never replayed. Measured 2026-10-14 (Codex records, 161 items across one day):
|
|
101
|
+
* every item the CLI replayed had the sealed blob, and most had an empty summary.
|
|
102
|
+
*/
|
|
103
|
+
export function replayableReasoning(stored) {
|
|
104
|
+
if (stored.type !== "reasoning")
|
|
105
|
+
return null;
|
|
106
|
+
const encrypted = stored.encrypted_content;
|
|
107
|
+
if (typeof encrypted !== "string" || encrypted.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
// Only the fields the wire defines are forwarded: a vendor's item may carry bookkeeping
|
|
110
|
+
// (`internal_chat_message_metadata_passthrough`) that is meaningless and unverified to send back.
|
|
111
|
+
const out = { type: "reasoning", encrypted_content: encrypted };
|
|
112
|
+
if (typeof stored.id === "string" && stored.id.length > 0)
|
|
113
|
+
out.id = stored.id;
|
|
114
|
+
if (Array.isArray(stored.summary))
|
|
115
|
+
out.summary = stored.summary;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The envelope on an assistant block, when it is ours *and* usable here: the right wire, and the
|
|
120
|
+
* same provider and model that made it. Anything else returns null as if the block were foreign —
|
|
121
|
+
* reasoning is state, and state from another model is not merely useless, it is the thing a vendor
|
|
122
|
+
* refuses. A signature that is ours but broken, or names another model, reports through `onDrop` so
|
|
123
|
+
* the caller can count it; neither is an error worth failing a turn over.
|
|
124
|
+
*/
|
|
125
|
+
export function envelopeFor(block, wire, env, onDrop) {
|
|
126
|
+
const b = block;
|
|
127
|
+
if (!b || typeof b !== "object" || b.type !== "thinking" || !isOurSignature(b.signature))
|
|
128
|
+
return null;
|
|
129
|
+
let decoded;
|
|
130
|
+
try {
|
|
131
|
+
const got = decodeEnvelope(b.signature);
|
|
132
|
+
if (!got)
|
|
133
|
+
return null;
|
|
134
|
+
decoded = got;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
onDrop("the envelope did not decode");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (decoded.w !== wire)
|
|
141
|
+
return null;
|
|
142
|
+
if (env && (decoded.p !== env.p || decoded.m !== env.m)) {
|
|
143
|
+
onDrop(`reasoning came from ${decoded.p}/${decoded.m}`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return decoded;
|
|
147
|
+
}
|
|
148
|
+
/** `type` matters as much as the wording: a vendor that says "reasoning" is answering about this. */
|
|
149
|
+
const REASONING_REFUSAL = /reasoning|encrypted|\binput\[\d+\]|\bitem\b|signature/i;
|
|
150
|
+
/**
|
|
151
|
+
* Whether a 4xx refusal is about the reasoning that was replayed, and so is worth one retry without
|
|
152
|
+
* it. Only a request that carried replayed reasoning can be refused for carrying it, and a sealed
|
|
153
|
+
* blob minted on one account or one model is the thing most likely to be refused on another.
|
|
154
|
+
*/
|
|
155
|
+
export function refusesReplayedReasoning(status, message) {
|
|
156
|
+
return status >= 400 && status < 500 && (status === 422 || REASONING_REFUSAL.test(message));
|
|
157
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// A remote (SSH) host that runs Claude Code for a Claude Desktop session (issue #67).
|
|
2
|
+
//
|
|
3
|
+
// Desktop's Code tab can run a session on a Linux host over SSH. Claude Code then runs *there*,
|
|
4
|
+
// reads that host's `~/.claude/settings.json`, and has no route to this router: 127.0.0.1 on the
|
|
5
|
+
// remote is the remote. Desktop opens its SSH connection with the bundled ssh2 library, which only
|
|
6
|
+
// forwards local→remote and never reads `RemoteForward` from ~/.ssh/config, so the router holds its
|
|
7
|
+
// own reverse tunnel (remote-tunnel.ts) and this module puts on the remote what a session there
|
|
8
|
+
// needs: the CA certificate, the env that points Claude Code at the tunnel, and the worker agent
|
|
9
|
+
// files. See docs/ARCHITECTURE.md "Remote (SSH) sessions".
|
|
10
|
+
//
|
|
11
|
+
// Everything on the remote is done by POSIX `sh` scripts fed to `ssh <host> sh -s` on stdin. No
|
|
12
|
+
// node or python there, no quoting of a command line through whatever login shell the remote user
|
|
13
|
+
// has, and the host name only ever travels as its own argv element — never through a local shell.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
/** The remote port the tunnel listens on (remote 127.0.0.1) unless `--port` says otherwise. */
|
|
19
|
+
export const DEFAULT_REMOTE_PORT = 18791;
|
|
20
|
+
/** Our directory under the remote home: the CA copy and the record of what we put there. */
|
|
21
|
+
export const REMOTE_DIR = ".clauderipple-remote";
|
|
22
|
+
/** The remote settings.json is copied here once, before the first time we change it. */
|
|
23
|
+
export const SETTINGS_BACKUP_SUFFIX = ".clauderipple-backup";
|
|
24
|
+
/**
|
|
25
|
+
* Why `host` cannot be handed to ssh, or null when it can. An ssh alias or `user@host`, nothing
|
|
26
|
+
* else: a leading `-` would be read as an option (`-oProxyCommand=…` runs a command), and anything
|
|
27
|
+
* outside a hostname's characters has no business in one.
|
|
28
|
+
*/
|
|
29
|
+
export function hostProblem(host) {
|
|
30
|
+
if (typeof host !== "string" || host === "")
|
|
31
|
+
return "an ssh host is required (an ssh alias or user@host)";
|
|
32
|
+
if (host.length > 255)
|
|
33
|
+
return "the ssh host is longer than 255 characters";
|
|
34
|
+
if (host.startsWith("-"))
|
|
35
|
+
return `"${host}" starts with "-", which ssh would read as an option`;
|
|
36
|
+
if (!/^[A-Za-z0-9_.@:-]+$/.test(host))
|
|
37
|
+
return `"${host}" is not an ssh alias or user@host (letters, digits and . _ @ : - only)`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function portProblem(port) {
|
|
41
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? null : `port must be an integer from 1 to 65535`;
|
|
42
|
+
}
|
|
43
|
+
/** The OpenSSH client: Windows ships it as an optional feature in System32\OpenSSH. */
|
|
44
|
+
export function sshExecutable() {
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
return "ssh";
|
|
47
|
+
const builtIn = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "OpenSSH", "ssh.exe");
|
|
48
|
+
return fs.existsSync(builtIn) ? builtIn : "ssh.exe";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Extra ssh arguments from `CLAUDERIPPLE_SSH_OPTIONS` (a JSON array of strings), placed before the
|
|
52
|
+
* host. Exists for the end-to-end test, which points ssh at a throwaway sshd with its own key and
|
|
53
|
+
* known_hosts instead of the user's ssh setup; nothing in the product sets it.
|
|
54
|
+
*/
|
|
55
|
+
export function extraSshOptions() {
|
|
56
|
+
const raw = process.env.CLAUDERIPPLE_SSH_OPTIONS;
|
|
57
|
+
if (!raw)
|
|
58
|
+
return [];
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!Array.isArray(parsed) || parsed.some((a) => typeof a !== "string"))
|
|
61
|
+
throw new Error("CLAUDERIPPLE_SSH_OPTIONS must be a JSON array of strings");
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
/** Never ask: a prompt nobody can answer would hang the router or the CLI. */
|
|
65
|
+
export const BATCH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
|
|
66
|
+
export const SSH_MISSING = "ssh was not found. Install the OpenSSH client (on Windows: Settings → System → Optional features → OpenSSH Client).";
|
|
67
|
+
export const spawnSsh = (args, opts = {}) => new Promise((resolve) => {
|
|
68
|
+
let stdout = "";
|
|
69
|
+
let stderr = "";
|
|
70
|
+
let settled = false;
|
|
71
|
+
const done = (r) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve(r);
|
|
77
|
+
};
|
|
78
|
+
const child = spawn(sshExecutable(), args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
child.kill("SIGKILL");
|
|
81
|
+
done({ code: null, stdout, stderr, error: `ssh timed out after ${Math.round((opts.timeoutMs ?? 60_000) / 1000)}s` });
|
|
82
|
+
}, opts.timeoutMs ?? 60_000);
|
|
83
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
84
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
85
|
+
child.stdin.on("error", () => { }); // ssh gone before reading its input: the exit says why
|
|
86
|
+
child.on("error", (e) => done({ code: null, stdout, stderr, error: e.code === "ENOENT" ? SSH_MISSING : e.message }));
|
|
87
|
+
child.on("close", (code) => done({ code, stdout, stderr }));
|
|
88
|
+
child.stdin.end(opts.input ?? "");
|
|
89
|
+
});
|
|
90
|
+
/** The last non-empty line ssh printed, which is where it says what went wrong. */
|
|
91
|
+
function lastLine(text) {
|
|
92
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop() ?? "";
|
|
93
|
+
}
|
|
94
|
+
/** Run a POSIX sh script on `host` and return its stdout. Throws with ssh's own words on failure. */
|
|
95
|
+
export async function runRemoteScript(run, host, script, timeoutMs = 60_000) {
|
|
96
|
+
const problem = hostProblem(host);
|
|
97
|
+
if (problem)
|
|
98
|
+
throw new Error(problem);
|
|
99
|
+
const r = await run([...BATCH_OPTIONS, ...extraSshOptions(), host, "sh", "-s"], { input: script, timeoutMs });
|
|
100
|
+
if (r.error)
|
|
101
|
+
throw new Error(r.error);
|
|
102
|
+
if (r.code === 255)
|
|
103
|
+
throw new SshConnectError(host, lastLine(r.stderr) || "ssh exited with 255");
|
|
104
|
+
if (r.code !== 0)
|
|
105
|
+
throw new Error(`script on ${host} failed (exit ${r.code}): ${lastLine(r.stderr) || "no message"}`);
|
|
106
|
+
return r.stdout;
|
|
107
|
+
}
|
|
108
|
+
/** ssh itself could not connect or authenticate (exit 255), as opposed to our script failing. */
|
|
109
|
+
export class SshConnectError extends Error {
|
|
110
|
+
host;
|
|
111
|
+
detail;
|
|
112
|
+
constructor(host, detail) {
|
|
113
|
+
super(`cannot reach ${host} over ssh: ${detail}`);
|
|
114
|
+
this.host = host;
|
|
115
|
+
this.detail = detail;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const AGENT_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
119
|
+
export function remoteCaPath(home) {
|
|
120
|
+
return `${home}/${REMOTE_DIR}/ca.pem`;
|
|
121
|
+
}
|
|
122
|
+
export function remoteProxyUrl(port) {
|
|
123
|
+
return `http://127.0.0.1:${port}`;
|
|
124
|
+
}
|
|
125
|
+
export async function inspectRemote(run, host) {
|
|
126
|
+
const m = `@@cr-${crypto.randomBytes(6).toString("hex")}`;
|
|
127
|
+
const script = [
|
|
128
|
+
`m='${m}'`,
|
|
129
|
+
`f="$HOME/.claude/settings.json"`,
|
|
130
|
+
`printf '%s home\\n' "$m"; printf '%s\\n' "$HOME"`,
|
|
131
|
+
`printf '%s settings\\n' "$m"`,
|
|
132
|
+
`if [ -e "$f" ]; then printf 'present\\n'; cat "$f" || exit 1; printf '\\n'; else printf 'missing\\n'; fi`,
|
|
133
|
+
`printf '%s sum\\n' "$m"`,
|
|
134
|
+
`if [ -e "$f" ]; then cksum < "$f" || exit 1; fi`,
|
|
135
|
+
`printf '%s backup\\n' "$m"`,
|
|
136
|
+
`if [ -e "$f${SETTINGS_BACKUP_SUFFIX}" ]; then printf 'yes\\n'; else printf 'no\\n'; fi`,
|
|
137
|
+
`printf '%s record\\n' "$m"`,
|
|
138
|
+
`if [ -e "$HOME/${REMOTE_DIR}/agents.json" ]; then cat "$HOME/${REMOTE_DIR}/agents.json"; printf '\\n'; fi`,
|
|
139
|
+
`printf '%s agents\\n' "$m"`,
|
|
140
|
+
`for x in "$HOME/.claude/agents"/*.md; do if [ -e "$x" ]; then basename "$x" .md; fi; done`,
|
|
141
|
+
`printf '%s end\\n' "$m"`,
|
|
142
|
+
`exit 0`,
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
const out = await runRemoteScript(run, host, script);
|
|
146
|
+
const sections = new Map();
|
|
147
|
+
let current = null;
|
|
148
|
+
for (const line of out.split("\n")) {
|
|
149
|
+
if (line.startsWith(`${m} `)) {
|
|
150
|
+
current = [];
|
|
151
|
+
sections.set(line.slice(m.length + 1).trim(), current);
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
current?.push(line);
|
|
155
|
+
}
|
|
156
|
+
if (!sections.has("end"))
|
|
157
|
+
throw new Error(`unexpected answer from ${host} (is its shell POSIX sh?)`);
|
|
158
|
+
const text = (name) => (sections.get(name) ?? []).join("\n");
|
|
159
|
+
const home = text("home").trim();
|
|
160
|
+
if (!home.startsWith("/"))
|
|
161
|
+
throw new Error(`${host} reported HOME="${home}"; a remote host must be Linux or another POSIX system`);
|
|
162
|
+
const settingsLines = sections.get("settings") ?? [];
|
|
163
|
+
const present = settingsLines[0] === "present";
|
|
164
|
+
let record = null;
|
|
165
|
+
const recordText = text("record").trim();
|
|
166
|
+
if (recordText) {
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(recordText);
|
|
169
|
+
record = {
|
|
170
|
+
agents: Array.isArray(parsed.agents) ? parsed.agents.filter((a) => typeof a === "string" && AGENT_NAME.test(a)) : [],
|
|
171
|
+
...(typeof parsed.proxyUrl === "string" ? { proxyUrl: parsed.proxyUrl } : {}),
|
|
172
|
+
...(typeof parsed.caPath === "string" ? { caPath: parsed.caPath } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
record = { agents: [] }; // ours but unreadable: nothing in it can be trusted as a delete list
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
home,
|
|
181
|
+
settings: present ? settingsLines.slice(1).join("\n") : null,
|
|
182
|
+
settingsSum: present ? text("sum").trim() || null : null,
|
|
183
|
+
backup: text("backup").trim() === "yes",
|
|
184
|
+
record,
|
|
185
|
+
agentFiles: text("agents").split("\n").map((l) => l.trim()).filter(Boolean),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ---- Writing to the remote ----------------------------------------------------------------
|
|
189
|
+
/** `"$HOME/<rel>"` for a path we built ourselves; refuses anything a shell could read differently. */
|
|
190
|
+
function homePath(rel) {
|
|
191
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(rel) || rel.split("/").includes(".."))
|
|
192
|
+
throw new Error(`refusing remote path "${rel}"`);
|
|
193
|
+
return `"$HOME/${rel}"`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Lines that replace `$HOME/<rel>` with `content`: a quoted heredoc into a temp file, then `mv`, so
|
|
197
|
+
* a reader sees the old file or the new one. A quoted delimiter means the shell expands nothing in
|
|
198
|
+
* the body. A heredoc always ends in a newline, so content without one gains one.
|
|
199
|
+
*/
|
|
200
|
+
export function writeFileLines(rel, content) {
|
|
201
|
+
const body = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
202
|
+
let delim = "";
|
|
203
|
+
do
|
|
204
|
+
delim = `CLAUDERIPPLE_EOF_${crypto.randomBytes(8).toString("hex")}`;
|
|
205
|
+
while (body.split("\n").includes(delim));
|
|
206
|
+
const target = homePath(rel);
|
|
207
|
+
const tmp = homePath(`${rel}.clauderipple-tmp`);
|
|
208
|
+
return [`cat > ${tmp} <<'${delim}' || exit 1`, body, delim, `mv -f ${tmp} ${target} || exit 1`];
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Stops the script (exit 3) when the remote settings.json is no longer the file `state` read. Put
|
|
212
|
+
* first in a script that will write it, so a refusal leaves nothing half done.
|
|
213
|
+
*/
|
|
214
|
+
export function settingsGuardLine(state) {
|
|
215
|
+
const f = `"$HOME/.claude/settings.json"`;
|
|
216
|
+
return state.settingsSum === null
|
|
217
|
+
? `if [ -e ${f} ]; then echo "~/.claude/settings.json appeared while it was being edited; run the command again" >&2; exit 3; fi`
|
|
218
|
+
: `if [ "$(cksum < ${f})" != '${state.settingsSum.replace(/'/g, "")}' ]; then echo "~/.claude/settings.json changed while it was being edited; run the command again" >&2; exit 3; fi`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Lines that write the remote settings.json, but only if it is still the file `state` read: a
|
|
222
|
+
* session or the user may have changed it in between, and that change must not be overwritten.
|
|
223
|
+
* The first time we change it, the original is copied to `settings.json.clauderipple-backup`.
|
|
224
|
+
*/
|
|
225
|
+
export function settingsWriteLines(state, content) {
|
|
226
|
+
const f = `"$HOME/.claude/settings.json"`;
|
|
227
|
+
return [
|
|
228
|
+
`mkdir -p "$HOME/.claude" || exit 1`,
|
|
229
|
+
settingsGuardLine(state),
|
|
230
|
+
`if [ -e ${f} ] && [ ! -e "$HOME/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}" ]; then cp -p ${f} "$HOME/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}" || exit 1; fi`,
|
|
231
|
+
...writeFileLines(".claude/settings.json", content),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The router's CA certificate — the file the local install points NODE_EXTRA_CA_CERTS at,
|
|
236
|
+
* `<home>/ca.pem` (cli/src/settings.ts via certPaths) — and the agent files this router generated,
|
|
237
|
+
* as `<home>/generated-agents.json` lists them (agents.ts). Only the public certificate: the file
|
|
238
|
+
* is checked to be one, so a key can never travel by mistake.
|
|
239
|
+
*/
|
|
240
|
+
export function readLocalAssets(opts) {
|
|
241
|
+
const caFile = path.join(opts.home, "ca.pem");
|
|
242
|
+
let caPem;
|
|
243
|
+
try {
|
|
244
|
+
caPem = fs.readFileSync(caFile, "utf8");
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
throw new Error(`no CA certificate at ${caFile}; run \`clauderipple install\` first`);
|
|
248
|
+
}
|
|
249
|
+
if (!caPem.includes("-----BEGIN CERTIFICATE-----") || /PRIVATE KEY/.test(caPem))
|
|
250
|
+
throw new Error(`${caFile} is not a certificate; refusing to copy it`);
|
|
251
|
+
let names = [];
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(opts.home, "generated-agents.json"), "utf8"));
|
|
254
|
+
if (Array.isArray(parsed.agents))
|
|
255
|
+
names = parsed.agents.filter((a) => typeof a === "string" && AGENT_NAME.test(a));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* no generated agents yet */
|
|
259
|
+
}
|
|
260
|
+
const agents = [];
|
|
261
|
+
for (const name of [...names].sort()) {
|
|
262
|
+
try {
|
|
263
|
+
agents.push({ name, content: fs.readFileSync(path.join(opts.agentDir, `${name}.md`), "utf8") });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* listed but gone: the router removes it from the manifest on its next sync */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { caPem, agents };
|
|
270
|
+
}
|
|
271
|
+
/** Changes whenever what a remote should hold changes; a tunnel re-syncs only on a new value. */
|
|
272
|
+
export function assetsFingerprint(a) {
|
|
273
|
+
const h = crypto.createHash("sha256").update(a.caPem);
|
|
274
|
+
for (const agent of a.agents)
|
|
275
|
+
h.update(`\0${agent.name}\0${agent.content}`);
|
|
276
|
+
return h.digest("hex");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Which agent files to write and remove on the remote. A remote file of the same name that we did
|
|
280
|
+
* not put there is someone's own agent and is left alone; a file we put there whose worker is no
|
|
281
|
+
* longer generated here goes.
|
|
282
|
+
*/
|
|
283
|
+
export function planAgents(local, state) {
|
|
284
|
+
const ours = new Set(state.record?.agents ?? []);
|
|
285
|
+
const present = new Set(state.agentFiles);
|
|
286
|
+
const write = local.filter((n) => !present.has(n) || ours.has(n));
|
|
287
|
+
const skipped = local.filter((n) => present.has(n) && !ours.has(n));
|
|
288
|
+
const remove = [...ours].filter((n) => !local.includes(n));
|
|
289
|
+
return { write, skipped, remove };
|
|
290
|
+
}
|
|
291
|
+
/** Lines that put the CA and the planned agent files on the remote and record what we own. */
|
|
292
|
+
export function assetLines(assets, plan, record) {
|
|
293
|
+
const lines = [`mkdir -p "$HOME/${REMOTE_DIR}" "$HOME/.claude/agents" || exit 1`, ...writeFileLines(`${REMOTE_DIR}/ca.pem`, assets.caPem)];
|
|
294
|
+
for (const name of plan.write) {
|
|
295
|
+
const agent = assets.agents.find((a) => a.name === name);
|
|
296
|
+
if (agent && AGENT_NAME.test(name))
|
|
297
|
+
lines.push(...writeFileLines(`.claude/agents/${name}.md`, agent.content));
|
|
298
|
+
}
|
|
299
|
+
for (const name of plan.remove)
|
|
300
|
+
if (AGENT_NAME.test(name))
|
|
301
|
+
lines.push(`rm -f ${homePath(`.claude/agents/${name}.md`)}`);
|
|
302
|
+
const next = { agents: [...plan.write].sort(), ...record };
|
|
303
|
+
lines.push(...writeFileLines(`${REMOTE_DIR}/agents.json`, JSON.stringify(next, null, 2) + "\n"));
|
|
304
|
+
return lines;
|
|
305
|
+
}
|
|
306
|
+
export function script(lines) {
|
|
307
|
+
return ["umask 077", ...lines, "exit 0", ""].join("\n");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Bring the CA and agent files on `host` in step with this machine. What the tunnel runs each time
|
|
311
|
+
* it comes up and whenever the router regenerates the agent files; settings.json is not touched
|
|
312
|
+
* (that is `remote add`'s job, once).
|
|
313
|
+
*/
|
|
314
|
+
export async function syncRemoteAssets(run, host, assets) {
|
|
315
|
+
const state = await inspectRemote(run, host);
|
|
316
|
+
const plan = planAgents(assets.agents.map((a) => a.name), state);
|
|
317
|
+
const { agents: _agents, ...kept } = state.record ?? { agents: [] };
|
|
318
|
+
await runRemoteScript(run, host, script(assetLines(assets, plan, kept)));
|
|
319
|
+
return { home: state.home, plan };
|
|
320
|
+
}
|