@hellcoder/companion 0.98.0 → 0.98.1-preview.20260515064257.15d15e5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentsPage-BL55sxWq.js → AgentsPage-rAYJ4cSY.js} +1 -1
- package/dist/assets/{CronManager-DkycDZO7.js → CronManager-D3Evml4L.js} +1 -1
- package/dist/assets/{IntegrationsPage-CKzuwKSL.js → IntegrationsPage-BlTiQtFv.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-D7FyIauR.js → LinearOAuthSettingsPage-C20zrdLZ.js} +1 -1
- package/dist/assets/{LinearSettingsPage-Cu39iFUZ.js → LinearSettingsPage-YbEOl57Z.js} +1 -1
- package/dist/assets/{Playground-KsXOh1KL.js → Playground-peCPHUft.js} +1 -1
- package/dist/assets/{PromptsPage-BSmcBPRr.js → PromptsPage-B6L_sVBa.js} +1 -1
- package/dist/assets/{RunsPage-Bdyig9xV.js → RunsPage-B4fzcy4J.js} +1 -1
- package/dist/assets/{SandboxManager-BVls-Ijd.js → SandboxManager-BPVyIBrj.js} +1 -1
- package/dist/assets/{SettingsPage-B6PJ98wg.js → SettingsPage-jrLolcpw.js} +1 -1
- package/dist/assets/{TailscalePage-OzuS9aO8.js → TailscalePage-DpGOGAEd.js} +1 -1
- package/dist/assets/index-BjomRUsd.css +1 -0
- package/dist/assets/{index-DKeYkY1b.js → index-Du0oTC_8.js} +51 -51
- package/dist/assets/{sw-register-lhAZpK0T.js → sw-register-DE3JFRS4.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-compat-checker.ts +221 -0
- package/server/claude-patcher.test.ts +85 -0
- package/server/claude-patcher.ts +258 -0
- package/server/claude-tls.ts +84 -0
- package/server/claude-versions.test.ts +96 -0
- package/server/claude-versions.ts +76 -0
- package/server/cli-ingress-server.ts +101 -0
- package/server/cli-launcher.ts +25 -5
- package/server/index.ts +26 -0
- package/server/routes/system-routes.ts +134 -1
- package/server/settings-manager.test.ts +9 -0
- package/server/settings-manager.ts +30 -1
- package/dist/assets/index-DwVmncqT.css +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-signed TLS material for the [::1] CLI ingress listener.
|
|
3
|
+
*
|
|
4
|
+
* After we patch the Claude CLI binary so it accepts `wss://[::1]:<port>/...`,
|
|
5
|
+
* the scheme check still requires TLS. We don't need real PKI: we generate
|
|
6
|
+
* an ephemeral self-signed cert (SAN IP:::1) once, cache it under
|
|
7
|
+
* ~/.companion/tls/, and spawn Claude with NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
8
|
+
* so its WebSocket client trusts it without keychain integration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
14
|
+
|
|
15
|
+
const TLS_DIR = join(COMPANION_HOME, "tls");
|
|
16
|
+
const CERT_PATH = join(TLS_DIR, "cli-bridge.cert.pem");
|
|
17
|
+
const KEY_PATH = join(TLS_DIR, "cli-bridge.key.pem");
|
|
18
|
+
|
|
19
|
+
/** Re-generate if the existing cert is within this many ms of expiry. */
|
|
20
|
+
const RENEW_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
21
|
+
|
|
22
|
+
export interface CliBridgeCert {
|
|
23
|
+
cert: string;
|
|
24
|
+
key: string;
|
|
25
|
+
certPath: string;
|
|
26
|
+
keyPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function certIsCurrent(): boolean {
|
|
30
|
+
if (!existsSync(CERT_PATH) || !existsSync(KEY_PATH)) return false;
|
|
31
|
+
try {
|
|
32
|
+
// openssl puts validity into the cert itself, but stat mtime is a cheap
|
|
33
|
+
// proxy: we issued the cert with `-days 3650`, so any file written less
|
|
34
|
+
// than ~3620 days ago is still well-within validity.
|
|
35
|
+
const stat = statSync(CERT_PATH);
|
|
36
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
37
|
+
const validForMs = 3650 * 24 * 60 * 60 * 1000;
|
|
38
|
+
return ageMs < validForMs - RENEW_THRESHOLD_MS;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function generateCert(): Promise<void> {
|
|
45
|
+
mkdirSync(TLS_DIR, { recursive: true });
|
|
46
|
+
const proc = Bun.spawn(
|
|
47
|
+
[
|
|
48
|
+
"openssl", "req", "-x509",
|
|
49
|
+
"-newkey", "rsa:2048",
|
|
50
|
+
"-keyout", KEY_PATH,
|
|
51
|
+
"-out", CERT_PATH,
|
|
52
|
+
"-days", "3650",
|
|
53
|
+
"-nodes",
|
|
54
|
+
"-subj", "/CN=companion-cli-bridge",
|
|
55
|
+
"-addext", "subjectAltName=IP:::1,IP:127.0.0.1",
|
|
56
|
+
],
|
|
57
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
58
|
+
);
|
|
59
|
+
const exitCode = await proc.exited;
|
|
60
|
+
if (exitCode !== 0) {
|
|
61
|
+
const stderr = await new Response(proc.stderr).text();
|
|
62
|
+
throw new Error(
|
|
63
|
+
`openssl cert generation failed (exit ${exitCode}): ${stderr.trim()}.` +
|
|
64
|
+
` Is openssl installed?`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the cert + key for the CLI ingress listener, generating them if
|
|
71
|
+
* missing or near expiry. The returned strings are the PEM contents, suitable
|
|
72
|
+
* for passing directly to `Bun.serve({ tls: { cert, key } })`.
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureCliBridgeCert(): Promise<CliBridgeCert> {
|
|
75
|
+
if (!certIsCurrent()) {
|
|
76
|
+
await generateCert();
|
|
77
|
+
}
|
|
78
|
+
const cert = readFileSync(CERT_PATH, "utf-8");
|
|
79
|
+
const key = readFileSync(KEY_PATH, "utf-8");
|
|
80
|
+
return { cert, key, certPath: CERT_PATH, keyPath: KEY_PATH };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test-only: directory the cert lives in (under COMPANION_HOME). */
|
|
84
|
+
export const _TLS_DIR_FOR_TEST = TLS_DIR;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseClaudeVersion,
|
|
4
|
+
compareVersions,
|
|
5
|
+
formatVersion,
|
|
6
|
+
isIncompatibleVersion,
|
|
7
|
+
isKnownGoodVersion,
|
|
8
|
+
pickPinTarget,
|
|
9
|
+
KNOWN_GOOD_MAX,
|
|
10
|
+
KNOWN_BAD_MIN,
|
|
11
|
+
} from "./claude-versions.js";
|
|
12
|
+
|
|
13
|
+
describe("parseClaudeVersion", () => {
|
|
14
|
+
// Validates parsing across the two formats Claude has shipped:
|
|
15
|
+
// the modern "X.Y.Z (Claude Code)" form and the older "Claude Code X.Y.Z" form.
|
|
16
|
+
it("parses modern format from `claude --version`", () => {
|
|
17
|
+
expect(parseClaudeVersion("2.1.142 (Claude Code)")).toEqual({ major: 2, minor: 1, patch: 142 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("parses legacy format", () => {
|
|
21
|
+
expect(parseClaudeVersion("Claude Code 2.1.119")).toEqual({ major: 2, minor: 1, patch: 119 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("tolerates trailing whitespace and newlines from stdout", () => {
|
|
25
|
+
expect(parseClaudeVersion("2.1.120\n")).toEqual({ major: 2, minor: 1, patch: 120 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns null when no version-like substring is present", () => {
|
|
29
|
+
expect(parseClaudeVersion("Unknown command: --version")).toBeNull();
|
|
30
|
+
expect(parseClaudeVersion("")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("compareVersions", () => {
|
|
35
|
+
it("orders by major then minor then patch", () => {
|
|
36
|
+
expect(compareVersions({ major: 2, minor: 1, patch: 120 }, { major: 2, minor: 1, patch: 121 })).toBe(-1);
|
|
37
|
+
expect(compareVersions({ major: 2, minor: 1, patch: 121 }, { major: 2, minor: 1, patch: 120 })).toBe(1);
|
|
38
|
+
expect(compareVersions({ major: 2, minor: 1, patch: 120 }, { major: 2, minor: 1, patch: 120 })).toBe(0);
|
|
39
|
+
expect(compareVersions({ major: 1, minor: 9, patch: 999 }, { major: 2, minor: 0, patch: 0 })).toBe(-1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("isIncompatibleVersion / isKnownGoodVersion", () => {
|
|
44
|
+
// The lockdown shipped in 2.1.121. 2.1.120 is the last working version.
|
|
45
|
+
// These tests pin those boundary semantics so the constants don't silently drift.
|
|
46
|
+
it("treats 2.1.120 as the last known-good version", () => {
|
|
47
|
+
expect(isKnownGoodVersion({ major: 2, minor: 1, patch: 120 })).toBe(true);
|
|
48
|
+
expect(isIncompatibleVersion({ major: 2, minor: 1, patch: 120 })).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats 2.1.121 as the first incompatible version", () => {
|
|
52
|
+
expect(isKnownGoodVersion({ major: 2, minor: 1, patch: 121 })).toBe(false);
|
|
53
|
+
expect(isIncompatibleVersion({ major: 2, minor: 1, patch: 121 })).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("treats 2.1.142 (currently-shipped) as incompatible", () => {
|
|
57
|
+
expect(isIncompatibleVersion({ major: 2, minor: 1, patch: 142 })).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("aligns with the exported constants", () => {
|
|
61
|
+
expect(KNOWN_GOOD_MAX).toEqual({ major: 2, minor: 1, patch: 120 });
|
|
62
|
+
expect(KNOWN_BAD_MIN).toEqual({ major: 2, minor: 1, patch: 121 });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("pickPinTarget", () => {
|
|
67
|
+
// Companion offers to pin to "the latest cached version that still works".
|
|
68
|
+
// Important to skip incompatible versions even if they're newest on disk.
|
|
69
|
+
it("returns the highest known-good version from a mixed list", () => {
|
|
70
|
+
const result = pickPinTarget([
|
|
71
|
+
{ major: 2, minor: 1, patch: 119 },
|
|
72
|
+
{ major: 2, minor: 1, patch: 120 },
|
|
73
|
+
{ major: 2, minor: 1, patch: 142 },
|
|
74
|
+
]);
|
|
75
|
+
expect(result).toEqual({ major: 2, minor: 1, patch: 120 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores incompatible versions even if they're the newest available", () => {
|
|
79
|
+
const result = pickPinTarget([
|
|
80
|
+
{ major: 2, minor: 1, patch: 130 },
|
|
81
|
+
{ major: 2, minor: 1, patch: 119 },
|
|
82
|
+
]);
|
|
83
|
+
expect(result).toEqual({ major: 2, minor: 1, patch: 119 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when no candidate is known-good", () => {
|
|
87
|
+
expect(pickPinTarget([{ major: 2, minor: 1, patch: 142 }])).toBeNull();
|
|
88
|
+
expect(pickPinTarget([])).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("formatVersion", () => {
|
|
93
|
+
it("renders X.Y.Z without prefix", () => {
|
|
94
|
+
expect(formatVersion({ major: 2, minor: 1, patch: 120 })).toBe("2.1.120");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI version detection and compatibility.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code 2.1.121 (~2026-04-27) added a static hostname allowlist to the
|
|
5
|
+
* --sdk-url flag, breaking every third-party tool that bridges sessions to a
|
|
6
|
+
* local server. The validator (function `YR4`, set `KU5` in the bundle)
|
|
7
|
+
* accepts only:
|
|
8
|
+
* api.anthropic.com, api-staging.anthropic.com,
|
|
9
|
+
* beacon.claude-ai.staging.ant.dev, claude.fedstart.com,
|
|
10
|
+
* claude-staging.fedstart.com
|
|
11
|
+
* and only with wss:// or https:// schemes. No env-var override exists.
|
|
12
|
+
*
|
|
13
|
+
* Last known working version: 2.1.120.
|
|
14
|
+
* First broken version: 2.1.121.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ClaudeVersion {
|
|
18
|
+
major: number;
|
|
19
|
+
minor: number;
|
|
20
|
+
patch: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Last Claude Code CLI version that accepts arbitrary --sdk-url targets. */
|
|
24
|
+
export const KNOWN_GOOD_MAX: ClaudeVersion = { major: 2, minor: 1, patch: 120 };
|
|
25
|
+
|
|
26
|
+
/** First Claude Code CLI version that rejects non-Anthropic --sdk-url hosts. */
|
|
27
|
+
export const KNOWN_BAD_MIN: ClaudeVersion = { major: 2, minor: 1, patch: 121 };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse the output of `claude --version`, which looks like:
|
|
31
|
+
* "2.1.142 (Claude Code)"
|
|
32
|
+
* or sometimes (older builds):
|
|
33
|
+
* "Claude Code 2.1.119"
|
|
34
|
+
*/
|
|
35
|
+
export function parseClaudeVersion(raw: string): ClaudeVersion | null {
|
|
36
|
+
const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
37
|
+
if (!match) return null;
|
|
38
|
+
return {
|
|
39
|
+
major: Number(match[1]),
|
|
40
|
+
minor: Number(match[2]),
|
|
41
|
+
patch: Number(match[3]),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns -1 / 0 / 1 like Array.sort. */
|
|
46
|
+
export function compareVersions(a: ClaudeVersion, b: ClaudeVersion): number {
|
|
47
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
|
|
48
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
|
|
49
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatVersion(v: ClaudeVersion): string {
|
|
54
|
+
return `${v.major}.${v.minor}.${v.patch}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True if the version is on or after the lockdown (2.1.121+). */
|
|
58
|
+
export function isIncompatibleVersion(v: ClaudeVersion): boolean {
|
|
59
|
+
return compareVersions(v, KNOWN_BAD_MIN) >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True if the version is at or below 2.1.120 (accepts arbitrary --sdk-url). */
|
|
63
|
+
export function isKnownGoodVersion(v: ClaudeVersion): boolean {
|
|
64
|
+
return compareVersions(v, KNOWN_GOOD_MAX) <= 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pick the best version to roll back to from a list of cached versions.
|
|
69
|
+
* Returns the highest version that's still known-good (<= 2.1.120),
|
|
70
|
+
* or null if none of the candidates qualify.
|
|
71
|
+
*/
|
|
72
|
+
export function pickPinTarget(candidates: ClaudeVersion[]): ClaudeVersion | null {
|
|
73
|
+
const good = candidates.filter(isKnownGoodVersion);
|
|
74
|
+
if (good.length === 0) return null;
|
|
75
|
+
return good.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best));
|
|
76
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel WSS listener on [::1] for the patched Claude binary's --sdk-url.
|
|
3
|
+
*
|
|
4
|
+
* The main companion server stays on its existing port (plain HTTP for the
|
|
5
|
+
* browser UI). After the binary patch, the spawned Claude CLI connects to
|
|
6
|
+
* `wss://[::1]:<port>/ws/cli/<sessionId>` instead of the plain ws:// URL —
|
|
7
|
+
* the lockdown validator demands wss:// even with [::1] hostname allowed.
|
|
8
|
+
*
|
|
9
|
+
* We pick a random free port at startup, persist it to settings, and bind
|
|
10
|
+
* with a self-signed cert (SAN IP:::1). Listens only for the CLI upgrade
|
|
11
|
+
* path; everything else gets 404. Delegates open/message/close to the
|
|
12
|
+
* exact same WsBridge methods the main server uses, so the bridge code has
|
|
13
|
+
* no idea which listener delivered the socket.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ServerWebSocket } from "bun";
|
|
17
|
+
import type { WsBridge, SocketData } from "./ws-bridge.js";
|
|
18
|
+
import type { CliLauncher } from "./cli-launcher.js";
|
|
19
|
+
import { ensureCliBridgeCert } from "./claude-tls.js";
|
|
20
|
+
|
|
21
|
+
export interface CliIngressServer {
|
|
22
|
+
/** Canonical URL prefix to hand to spawned CLIs, e.g. "wss://[::1]:54321". */
|
|
23
|
+
urlPrefix: string;
|
|
24
|
+
port: number;
|
|
25
|
+
stop: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function startCliIngressServer(deps: {
|
|
29
|
+
wsBridge: WsBridge;
|
|
30
|
+
launcher: CliLauncher;
|
|
31
|
+
}): Promise<CliIngressServer> {
|
|
32
|
+
const { cert, key } = await ensureCliBridgeCert();
|
|
33
|
+
|
|
34
|
+
const server = Bun.serve<SocketData>({
|
|
35
|
+
hostname: "::1",
|
|
36
|
+
port: 0, // Bun picks a free port
|
|
37
|
+
idleTimeout: 0,
|
|
38
|
+
tls: { cert, key },
|
|
39
|
+
fetch(req, server) {
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
const match = url.pathname.match(/^\/ws\/cli\/([a-f0-9-]+)$/);
|
|
42
|
+
if (!match) {
|
|
43
|
+
return new Response("Not Found", { status: 404 });
|
|
44
|
+
}
|
|
45
|
+
const sessionId = match[1];
|
|
46
|
+
|
|
47
|
+
// jsonHandoff bridge tokens still apply when set — same logic as the
|
|
48
|
+
// main server's CLI upgrade handler. Sessions launched in the new
|
|
49
|
+
// "patched" mode don't use bridgeToken (TLS + loopback is the guard);
|
|
50
|
+
// but if a session was created with jsonHandoff this listener will
|
|
51
|
+
// honor the token check too, for symmetry.
|
|
52
|
+
const session = deps.launcher.getSession(sessionId);
|
|
53
|
+
if (session?.bridgeToken) {
|
|
54
|
+
const presented = url.searchParams.get("token")
|
|
55
|
+
|| req.headers.get("sec-websocket-protocol")?.split(",").map((s: string) => s.trim()).find(Boolean);
|
|
56
|
+
if (presented !== session.bridgeToken) {
|
|
57
|
+
return new Response("Unauthorized", { status: 401 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const upgraded = server.upgrade(req, {
|
|
62
|
+
data: { kind: "cli" as const, sessionId },
|
|
63
|
+
});
|
|
64
|
+
if (upgraded) return undefined;
|
|
65
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
66
|
+
},
|
|
67
|
+
websocket: {
|
|
68
|
+
idleTimeout: 0,
|
|
69
|
+
sendPings: false,
|
|
70
|
+
open(ws: ServerWebSocket<SocketData>) {
|
|
71
|
+
if (ws.data.kind !== "cli") return;
|
|
72
|
+
deps.wsBridge.handleCLIOpen(ws, ws.data.sessionId);
|
|
73
|
+
deps.launcher.markConnected(ws.data.sessionId);
|
|
74
|
+
},
|
|
75
|
+
message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
|
|
76
|
+
if (ws.data.kind !== "cli") return;
|
|
77
|
+
deps.wsBridge.handleCLIMessage(ws, msg);
|
|
78
|
+
},
|
|
79
|
+
close(ws: ServerWebSocket<SocketData>) {
|
|
80
|
+
if (ws.data.kind !== "cli") return;
|
|
81
|
+
deps.wsBridge.handleCLIClose(ws);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Bun.serve types server.port as number | undefined because port: 0 is
|
|
87
|
+
// technically resolvable late; in practice port is bound synchronously and
|
|
88
|
+
// populated by the time Bun.serve returns. Crash early if not.
|
|
89
|
+
const port = server.port;
|
|
90
|
+
if (typeof port !== "number") {
|
|
91
|
+
throw new Error("Bun.serve did not assign a port to the CLI ingress listener");
|
|
92
|
+
}
|
|
93
|
+
const urlPrefix = `wss://[::1]:${port}`;
|
|
94
|
+
console.log(`[cli-ingress] Listening on ${urlPrefix} (TLS, patched-bridge mode)`);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
urlPrefix,
|
|
98
|
+
port,
|
|
99
|
+
stop: () => { server.stop(true); },
|
|
100
|
+
};
|
|
101
|
+
}
|
package/server/cli-launcher.ts
CHANGED
|
@@ -497,12 +497,28 @@ export class CliLauncher {
|
|
|
497
497
|
|
|
498
498
|
// When running inside a container, the SDK URL targets the host alias so
|
|
499
499
|
// the CLI can connect back to the Hono server running on the host.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// "
|
|
500
|
+
//
|
|
501
|
+
// For host sessions there are two paths depending on settings:
|
|
502
|
+
// * "patched" — the companion has byte-patched the local Claude binary
|
|
503
|
+
// so it accepts [::1] in --sdk-url, and a parallel TLS WS listener is
|
|
504
|
+
// running on wss://[::1]:<ingress-port>. We emit that URL and set
|
|
505
|
+
// NODE_TLS_REJECT_UNAUTHORIZED=0 below so the self-signed cert is
|
|
506
|
+
// accepted. Required on Claude Code >= 2.1.121, where the validator
|
|
507
|
+
// rejects every non-Anthropic host. See claude-versions.ts.
|
|
508
|
+
// * "none" (default) — plain ws://127.0.0.1:<port>/... Works on
|
|
509
|
+
// 2.1.120 and earlier; sessions on a stock 2.1.121+ binary will fail
|
|
510
|
+
// immediately with "host 127.0.0.1 is not an approved Anthropic
|
|
511
|
+
// endpoint" (visible only on direct CLI stderr).
|
|
512
|
+
const settings = getSettings();
|
|
513
|
+
const patchedBridge = !isContainerized
|
|
514
|
+
&& settings.claudeBridgeMode === "patched"
|
|
515
|
+
&& typeof settings.claudeBridgeIngressUrl === "string"
|
|
516
|
+
&& settings.claudeBridgeIngressUrl.length > 0;
|
|
503
517
|
const sdkUrl = isContainerized
|
|
504
518
|
? `ws://${containerSdkHost}:${this.port}/ws/cli/${sessionId}`
|
|
505
|
-
:
|
|
519
|
+
: patchedBridge
|
|
520
|
+
? `${settings.claudeBridgeIngressUrl}/ws/cli/${sessionId}`
|
|
521
|
+
: `ws://127.0.0.1:${this.port}/ws/cli/${sessionId}`;
|
|
506
522
|
|
|
507
523
|
// Claude Code rejects bypassPermissions when running with root/sudo.
|
|
508
524
|
// Container sessions are downgraded by default; host sessions are only
|
|
@@ -535,7 +551,7 @@ export class CliLauncher {
|
|
|
535
551
|
// path via CLAUDE_BRIDGE_CONFIG env var instead of --sdk-url on argv.
|
|
536
552
|
// This is forward-compatible if Anthropic further restricts --sdk-url
|
|
537
553
|
// (e.g. drops it entirely or adds origin/handshake checks).
|
|
538
|
-
const bridgeMode =
|
|
554
|
+
const bridgeMode = settings.cliBridgeMode ?? "loopback";
|
|
539
555
|
const useJsonHandoff = bridgeMode === "jsonHandoff" && !isContainerized;
|
|
540
556
|
let bridgeConfigPath: string | undefined;
|
|
541
557
|
if (useJsonHandoff) {
|
|
@@ -634,6 +650,10 @@ export class CliLauncher {
|
|
|
634
650
|
...options.env,
|
|
635
651
|
PATH: getEnrichedPath(),
|
|
636
652
|
...(bridgeConfigPath ? { CLAUDE_BRIDGE_CONFIG: bridgeConfigPath } : {}),
|
|
653
|
+
// Patched-bridge mode terminates --sdk-url at our self-signed wss://[::1]
|
|
654
|
+
// listener. Tell Bun/Node to trust the self-signed cert without involving
|
|
655
|
+
// the user's CA store.
|
|
656
|
+
...(patchedBridge ? { NODE_TLS_REJECT_UNAUTHORIZED: "0" } : {}),
|
|
637
657
|
};
|
|
638
658
|
spawnCwd = info.cwd;
|
|
639
659
|
}
|
package/server/index.ts
CHANGED
|
@@ -34,6 +34,12 @@ import { LinearAgentBridge } from "./linear-agent-bridge.js";
|
|
|
34
34
|
import { NoVncProxy } from "./novnc-proxy.js";
|
|
35
35
|
|
|
36
36
|
import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
|
|
37
|
+
import {
|
|
38
|
+
startPeriodicCheck as startClaudeCompatPeriodicCheck,
|
|
39
|
+
} from "./claude-compat-checker.js";
|
|
40
|
+
import { startCliIngressServer } from "./cli-ingress-server.js";
|
|
41
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
42
|
+
import { setCliIngressServer } from "./routes/system-routes.js";
|
|
37
43
|
import { imagePullManager } from "./image-pull-manager.js";
|
|
38
44
|
import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
|
|
39
45
|
import { isRunningAsService } from "./service.js";
|
|
@@ -360,6 +366,26 @@ restoreTailscaleFunnel(port).catch((err) => {
|
|
|
360
366
|
|
|
361
367
|
// ── Update checker ──────────────────────────────────────────────────────────
|
|
362
368
|
startPeriodicCheck();
|
|
369
|
+
|
|
370
|
+
// ── Claude CLI compatibility checker — surfaces banner when CLI is 2.1.121+ ─
|
|
371
|
+
startClaudeCompatPeriodicCheck();
|
|
372
|
+
|
|
373
|
+
// ── CLI ingress (TLS WSS on [::1]) — only when the user has patched their
|
|
374
|
+
// Claude binary; otherwise plain ws://127.0.0.1 on the main port is used.
|
|
375
|
+
// We re-bind a new random port each restart and persist it to settings so
|
|
376
|
+
// cli-launcher emits the current URL on next spawn.
|
|
377
|
+
if (getSettings().claudeBridgeMode === "patched") {
|
|
378
|
+
(async () => {
|
|
379
|
+
try {
|
|
380
|
+
const ingress = await startCliIngressServer({ wsBridge, launcher });
|
|
381
|
+
setCliIngressServer(ingress);
|
|
382
|
+
updateSettings({ claudeBridgeIngressUrl: ingress.urlPrefix });
|
|
383
|
+
} catch (err) {
|
|
384
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
385
|
+
console.warn(`[server] Patched bridge mode active but ingress start failed: ${msg}`);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
363
389
|
if (isRunningAsService()) {
|
|
364
390
|
setServiceMode(true);
|
|
365
391
|
console.log("[server] Running as background service (auto-update available)");
|
|
@@ -10,8 +10,24 @@ import {
|
|
|
10
10
|
setUpdateInProgress,
|
|
11
11
|
} from "../update-checker.js";
|
|
12
12
|
import { refreshServiceDefinition } from "../service.js";
|
|
13
|
-
import { getSettings } from "../settings-manager.js";
|
|
13
|
+
import { getSettings, updateSettings } from "../settings-manager.js";
|
|
14
14
|
import { imagePullManager } from "../image-pull-manager.js";
|
|
15
|
+
import { checkCompat, getCompatState } from "../claude-compat-checker.js";
|
|
16
|
+
import { pinToVersion, patchBinary, unpatch } from "../claude-patcher.js";
|
|
17
|
+
import {
|
|
18
|
+
startCliIngressServer,
|
|
19
|
+
type CliIngressServer,
|
|
20
|
+
} from "../cli-ingress-server.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Module-level handle to the running CLI ingress server (patched-bridge mode).
|
|
24
|
+
* Owned by this module so the patch / unpatch routes can start and stop it.
|
|
25
|
+
* Set by the bootstrap in index.ts when settings indicate patched mode at
|
|
26
|
+
* startup; otherwise populated by the /claude-compat/patch route.
|
|
27
|
+
*/
|
|
28
|
+
let cliIngress: CliIngressServer | null = null;
|
|
29
|
+
export function getCliIngressServer(): CliIngressServer | null { return cliIngress; }
|
|
30
|
+
export function setCliIngressServer(s: CliIngressServer | null): void { cliIngress = s; }
|
|
15
31
|
|
|
16
32
|
export function registerSystemRoutes(
|
|
17
33
|
api: Hono,
|
|
@@ -225,4 +241,121 @@ export function registerSystemRoutes(
|
|
|
225
241
|
deps.wsBridge.injectUserMessage(id, body.content);
|
|
226
242
|
return c.json({ ok: true, sessionId: id });
|
|
227
243
|
});
|
|
244
|
+
|
|
245
|
+
// ── Claude CLI compatibility (post-2.1.121 --sdk-url lockdown) ──────────────
|
|
246
|
+
// Read more in claude-versions.ts. The UI consumes /claude-compat to render a
|
|
247
|
+
// banner offering Pin (downgrade) or Patch (byte-replace + TLS bridge).
|
|
248
|
+
function compatPayload() {
|
|
249
|
+
const compat = getCompatState();
|
|
250
|
+
const settings = getSettings();
|
|
251
|
+
return {
|
|
252
|
+
installedVersion: compat.installedVersion,
|
|
253
|
+
installedPath: compat.installedPath,
|
|
254
|
+
isIncompatible: compat.isIncompatible,
|
|
255
|
+
isPatched: compat.isPatched,
|
|
256
|
+
availableKnownGood: compat.availableKnownGood,
|
|
257
|
+
suggestedPinTarget: compat.suggestedPinTarget,
|
|
258
|
+
lastChecked: compat.lastChecked,
|
|
259
|
+
error: compat.error,
|
|
260
|
+
bridgeMode: settings.claudeBridgeMode ?? "none",
|
|
261
|
+
ingressUrl: settings.claudeBridgeIngressUrl ?? "",
|
|
262
|
+
bannerDismissedVersion: settings.claudeCompatBannerDismissedVersion ?? "",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
api.get("/claude-compat", async (c) => {
|
|
267
|
+
const initial = getCompatState();
|
|
268
|
+
const staleMs = deps.updateCheckStaleMs;
|
|
269
|
+
if (initial.lastChecked === 0 || Date.now() - initial.lastChecked > staleMs) {
|
|
270
|
+
await checkCompat();
|
|
271
|
+
}
|
|
272
|
+
return c.json(compatPayload());
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
api.post("/claude-compat/refresh", async (c) => {
|
|
276
|
+
await checkCompat();
|
|
277
|
+
return c.json(compatPayload());
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
api.post("/claude-compat/pin", async (c) => {
|
|
281
|
+
const body = await c.req.json().catch(() => ({}));
|
|
282
|
+
const version = typeof body.version === "string" && body.version.trim()
|
|
283
|
+
? body.version.trim()
|
|
284
|
+
: getCompatState().suggestedPinTarget;
|
|
285
|
+
if (!version) {
|
|
286
|
+
return c.json({ error: "No known-good Claude version is cached locally to pin to." }, 400);
|
|
287
|
+
}
|
|
288
|
+
const res = await pinToVersion(version);
|
|
289
|
+
if (!res.ok) return c.json({ error: res.error }, 400);
|
|
290
|
+
|
|
291
|
+
// Pinning means we're back on a non-validator binary; turn off patched
|
|
292
|
+
// bridge mode so we don't continue routing through wss://[::1].
|
|
293
|
+
if (cliIngress) {
|
|
294
|
+
cliIngress.stop();
|
|
295
|
+
cliIngress = null;
|
|
296
|
+
}
|
|
297
|
+
updateSettings({ claudeBridgeMode: "none", claudeBridgeIngressUrl: "" });
|
|
298
|
+
|
|
299
|
+
await checkCompat();
|
|
300
|
+
return c.json({ ok: true, pinnedTo: version, ...compatPayload() });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
api.post("/claude-compat/patch", async (c) => {
|
|
304
|
+
const patchRes = await patchBinary();
|
|
305
|
+
if (!patchRes.ok) return c.json({ error: patchRes.error }, 400);
|
|
306
|
+
|
|
307
|
+
// Start (or restart) the TLS ingress listener and persist the URL so
|
|
308
|
+
// cli-launcher emits it on the next spawn.
|
|
309
|
+
if (cliIngress) {
|
|
310
|
+
cliIngress.stop();
|
|
311
|
+
cliIngress = null;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
cliIngress = await startCliIngressServer({
|
|
315
|
+
wsBridge: deps.wsBridge,
|
|
316
|
+
launcher: deps.launcher,
|
|
317
|
+
});
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
320
|
+
return c.json({ error: `Patched binary but TLS ingress failed: ${msg}` }, 500);
|
|
321
|
+
}
|
|
322
|
+
updateSettings({
|
|
323
|
+
claudeBridgeMode: "patched",
|
|
324
|
+
claudeBridgeIngressUrl: cliIngress.urlPrefix,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await checkCompat();
|
|
328
|
+
return c.json({
|
|
329
|
+
ok: true,
|
|
330
|
+
patchedPath: patchRes.patchedPath,
|
|
331
|
+
replacements: patchRes.replacements,
|
|
332
|
+
...compatPayload(),
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
api.post("/claude-compat/unpatch", async (c) => {
|
|
337
|
+
const res = await unpatch();
|
|
338
|
+
if (!res.ok) return c.json({ error: res.error }, 400);
|
|
339
|
+
|
|
340
|
+
if (cliIngress) {
|
|
341
|
+
cliIngress.stop();
|
|
342
|
+
cliIngress = null;
|
|
343
|
+
}
|
|
344
|
+
updateSettings({ claudeBridgeMode: "none", claudeBridgeIngressUrl: "" });
|
|
345
|
+
|
|
346
|
+
await checkCompat();
|
|
347
|
+
return c.json({ ok: true, target: res.target, ...compatPayload() });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
api.post("/claude-compat/dismiss-banner", async (c) => {
|
|
351
|
+
const body = await c.req.json().catch(() => ({}));
|
|
352
|
+
const version = typeof body.version === "string" && body.version.trim()
|
|
353
|
+
? body.version.trim()
|
|
354
|
+
: getCompatState().installedVersion ?? "";
|
|
355
|
+
if (!version) {
|
|
356
|
+
return c.json({ error: "No version available to record as dismissed" }, 400);
|
|
357
|
+
}
|
|
358
|
+
updateSettings({ claudeCompatBannerDismissedVersion: version });
|
|
359
|
+
return c.json({ ok: true, dismissedVersion: version });
|
|
360
|
+
});
|
|
228
361
|
}
|
|
@@ -49,6 +49,9 @@ describe("settings-manager", () => {
|
|
|
49
49
|
updateChannel: "stable",
|
|
50
50
|
dockerAutoUpdate: false,
|
|
51
51
|
cliBridgeMode: "loopback",
|
|
52
|
+
claudeBridgeMode: "none",
|
|
53
|
+
claudeBridgeIngressUrl: "",
|
|
54
|
+
claudeCompatBannerDismissedVersion: "",
|
|
52
55
|
updatedAt: 0,
|
|
53
56
|
});
|
|
54
57
|
});
|
|
@@ -105,6 +108,9 @@ describe("settings-manager", () => {
|
|
|
105
108
|
updateChannel: "stable",
|
|
106
109
|
dockerAutoUpdate: false,
|
|
107
110
|
cliBridgeMode: "loopback",
|
|
111
|
+
claudeBridgeMode: "none",
|
|
112
|
+
claudeBridgeIngressUrl: "",
|
|
113
|
+
claudeCompatBannerDismissedVersion: "",
|
|
108
114
|
updatedAt: 123,
|
|
109
115
|
});
|
|
110
116
|
});
|
|
@@ -185,6 +191,9 @@ describe("settings-manager", () => {
|
|
|
185
191
|
updateChannel: "stable",
|
|
186
192
|
dockerAutoUpdate: false,
|
|
187
193
|
cliBridgeMode: "loopback",
|
|
194
|
+
claudeBridgeMode: "none",
|
|
195
|
+
claudeBridgeIngressUrl: "",
|
|
196
|
+
claudeCompatBannerDismissedVersion: "",
|
|
188
197
|
updatedAt: 0,
|
|
189
198
|
});
|
|
190
199
|
});
|