@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.
Files changed (30) hide show
  1. package/dist/assets/{AgentsPage-BL55sxWq.js → AgentsPage-rAYJ4cSY.js} +1 -1
  2. package/dist/assets/{CronManager-DkycDZO7.js → CronManager-D3Evml4L.js} +1 -1
  3. package/dist/assets/{IntegrationsPage-CKzuwKSL.js → IntegrationsPage-BlTiQtFv.js} +1 -1
  4. package/dist/assets/{LinearOAuthSettingsPage-D7FyIauR.js → LinearOAuthSettingsPage-C20zrdLZ.js} +1 -1
  5. package/dist/assets/{LinearSettingsPage-Cu39iFUZ.js → LinearSettingsPage-YbEOl57Z.js} +1 -1
  6. package/dist/assets/{Playground-KsXOh1KL.js → Playground-peCPHUft.js} +1 -1
  7. package/dist/assets/{PromptsPage-BSmcBPRr.js → PromptsPage-B6L_sVBa.js} +1 -1
  8. package/dist/assets/{RunsPage-Bdyig9xV.js → RunsPage-B4fzcy4J.js} +1 -1
  9. package/dist/assets/{SandboxManager-BVls-Ijd.js → SandboxManager-BPVyIBrj.js} +1 -1
  10. package/dist/assets/{SettingsPage-B6PJ98wg.js → SettingsPage-jrLolcpw.js} +1 -1
  11. package/dist/assets/{TailscalePage-OzuS9aO8.js → TailscalePage-DpGOGAEd.js} +1 -1
  12. package/dist/assets/index-BjomRUsd.css +1 -0
  13. package/dist/assets/{index-DKeYkY1b.js → index-Du0oTC_8.js} +51 -51
  14. package/dist/assets/{sw-register-lhAZpK0T.js → sw-register-DE3JFRS4.js} +1 -1
  15. package/dist/index.html +2 -2
  16. package/dist/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/server/claude-compat-checker.ts +221 -0
  19. package/server/claude-patcher.test.ts +85 -0
  20. package/server/claude-patcher.ts +258 -0
  21. package/server/claude-tls.ts +84 -0
  22. package/server/claude-versions.test.ts +96 -0
  23. package/server/claude-versions.ts +76 -0
  24. package/server/cli-ingress-server.ts +101 -0
  25. package/server/cli-launcher.ts +25 -5
  26. package/server/index.ts +26 -0
  27. package/server/routes/system-routes.ts +134 -1
  28. package/server/settings-manager.test.ts +9 -0
  29. package/server/settings-manager.ts +30 -1
  30. 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
+ }
@@ -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
- // For host sessions, use the numeric loopback (127.0.0.1) instead of
501
- // "localhost": Claude Code v1.2.1+ rejects the literal hostname
502
- // "localhost" in --sdk-url as a CSWSH hardening measure (issue #655).
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
- : `ws://127.0.0.1:${this.port}/ws/cli/${sessionId}`;
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 = getSettings().cliBridgeMode ?? "loopback";
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
  });