@hellcoder/companion 0.98.1-preview.20260515064257.15d15e5 → 0.99.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.98.1-preview.20260515064257.15d15e5",
3
+ "version": "0.99.0",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -6,8 +6,8 @@
6
6
  * then refresh every CHECK_INTERVAL_MS. State is exposed via getCompatState().
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, readlinkSync, statSync } from "node:fs";
10
- import { join } from "node:path";
9
+ import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
13
13
  import {
@@ -85,22 +85,28 @@ async function spawnVersion(binary: string): Promise<string> {
85
85
  /**
86
86
  * Resolve the absolute file path the `claude` binary on PATH refers to.
87
87
  * Anthropic's installer puts a symlink in ~/.local/bin pointing into
88
- * ~/.local/share/claude/versions/<version>. Returns the final target file
89
- * after resolving one level of symlink.
88
+ * ~/.local/share/claude/versions/<version>. The companion patcher can also
89
+ * insert a `.patched` link in the chain. Walk lstat-based hops so we can
90
+ * tell symlinks from real files (statSync transparently follows symlinks
91
+ * and would always report isSymbolicLink() === false).
90
92
  */
91
93
  function resolveClaudeTarget(): string | null {
92
94
  const onPath = resolveBinary("claude");
93
95
  if (!onPath) return null;
94
- try {
95
- const st = statSync(onPath);
96
- if (st.isSymbolicLink()) {
97
- const target = readlinkSync(onPath);
98
- return target.startsWith("/") ? target : join(onPath, "..", target);
96
+ let current = onPath;
97
+ for (let i = 0; i < 16; i++) {
98
+ let st;
99
+ try {
100
+ st = lstatSync(current);
101
+ } catch {
102
+ return null;
99
103
  }
100
- return onPath;
101
- } catch {
102
- return onPath;
104
+ if (st.isFile()) return current;
105
+ if (!st.isSymbolicLink()) return null;
106
+ const target = readlinkSync(current);
107
+ current = target.startsWith("/") ? target : join(dirname(current), target);
103
108
  }
109
+ return null;
104
110
  }
105
111
 
106
112
  /**
@@ -128,19 +134,54 @@ function listCachedVersions(): ClaudeVersion[] {
128
134
 
129
135
  /**
130
136
  * Detect whether the binary at `path` has been patched.
131
- * Reads up to FIRST_CHUNK_BYTES from the start of the file and looks for the
132
- * marker string. Cheap the marker lives in a constant section near other
133
- * string literals, typically within the first 256 MiB; we only read 32 MiB
134
- * to keep this fast, which empirically covers it.
137
+ *
138
+ * The marker bytes can live anywhere in the binary in observed Claude 2.1.142
139
+ * builds, the closest occurrence is at offset ~166 MB out of 232 MB. A
140
+ * naive head-of-file slice misses it, so we stream the whole file in
141
+ * chunks and scan for the marker with boundary-safe leftover buffering.
142
+ * Returns true on the first match (early exit), so the typical cost is
143
+ * O(file size) only in the cold case where the binary is unpatched.
144
+ *
145
+ * Filename suffix is checked first as a fast-path: a binary at `<name>.patched`
146
+ * is almost certainly patched, and avoids the streaming scan in the common
147
+ * case where the symlink chain ends at our patcher's output.
135
148
  */
136
- const PATCH_DETECT_BYTES = 32 * 1024 * 1024;
137
149
  async function detectPatched(path: string): Promise<boolean> {
138
150
  try {
151
+ if (path.endsWith(".patched")) return true; // fast path
139
152
  const file = Bun.file(path);
140
153
  if (!(await file.exists())) return false;
141
- const slice = file.slice(0, Math.min(file.size, PATCH_DETECT_BYTES));
142
- const text = await slice.text();
143
- return text.includes(PATCHED_BINARY_MARKER);
154
+
155
+ const marker = new TextEncoder().encode(PATCHED_BINARY_MARKER);
156
+ const markerLen = marker.length;
157
+ let leftover = new Uint8Array(0);
158
+
159
+ // ReadableStream isn't typed as async-iterable in lib.dom, so use the
160
+ // explicit reader API. Bun yields Uint8Array chunks (typically ~64 KiB).
161
+ // We join with the previous chunk's tail so a marker that straddles a
162
+ // chunk boundary still matches.
163
+ const reader = file.stream().getReader();
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done || !value) break;
167
+ const combined = new Uint8Array(leftover.length + value.length);
168
+ combined.set(leftover, 0);
169
+ combined.set(value, leftover.length);
170
+
171
+ outer: for (let i = 0; i + markerLen <= combined.length; i++) {
172
+ for (let j = 0; j < markerLen; j++) {
173
+ if (combined[i + j] !== marker[j]) continue outer;
174
+ }
175
+ reader.cancel().catch(() => {});
176
+ return true;
177
+ }
178
+ // Keep the trailing (markerLen - 1) bytes so a marker spanning the
179
+ // next chunk boundary still matches.
180
+ leftover = markerLen > 1
181
+ ? combined.subarray(combined.length - (markerLen - 1))
182
+ : new Uint8Array(0);
183
+ }
184
+ return false;
144
185
  } catch {
145
186
  return false;
146
187
  }
@@ -20,8 +20,8 @@ import {
20
20
  existsSync,
21
21
  mkdirSync,
22
22
  readFileSync,
23
- readdirSync,
24
- statSync,
23
+ lstatSync,
24
+ readlinkSync,
25
25
  writeFileSync,
26
26
  chmodSync,
27
27
  } from "node:fs";
@@ -119,16 +119,28 @@ export async function pinToVersion(version: string): Promise<PatcherResult<{ tar
119
119
  }
120
120
  }
121
121
 
122
- /** Locate the binary the `claude` symlink currently resolves to. */
123
- function resolveCurrentBinaryPath(): string | null {
124
- if (!existsSync(CLAUDE_SYMLINK)) return null;
125
- try {
126
- const stat = statSync(CLAUDE_SYMLINK);
127
- if (!stat.isFile()) return null;
128
- return readFileSync(CLAUDE_SYMLINK).length > 0 ? CLAUDE_SYMLINK : null;
129
- } catch {
130
- return null;
122
+ /**
123
+ * Resolve `~/.local/bin/claude` to the absolute path of the real file the
124
+ * symlink chain ends at — e.g. `~/.local/share/claude/versions/2.1.142` or
125
+ * `~/.local/share/claude/versions/2.1.142.patched`. Uses lstatSync at each
126
+ * step so we never accidentally follow a symlink without realising. Returns
127
+ * null if the path doesn't exist or isn't ultimately a regular file.
128
+ */
129
+ function resolveSymlinkChain(startPath: string): string | null {
130
+ let current = startPath;
131
+ for (let i = 0; i < 16; i++) { // sane hop limit to avoid loops
132
+ let stat;
133
+ try {
134
+ stat = lstatSync(current);
135
+ } catch {
136
+ return null;
137
+ }
138
+ if (stat.isFile()) return current;
139
+ if (!stat.isSymbolicLink()) return null;
140
+ const link = readlinkSync(current);
141
+ current = link.startsWith("/") ? link : join(dirname(current), link);
131
142
  }
143
+ return null;
132
144
  }
133
145
 
134
146
  /**
@@ -145,24 +157,20 @@ function resolveCurrentBinaryPath(): string | null {
145
157
  * alongside as <X.Y.Z>.patched.
146
158
  */
147
159
  export async function patchBinary(): Promise<PatcherResult<{ patchedPath: string; replacements: number }>> {
148
- const source = resolveCurrentBinaryPath();
149
- if (!source) {
160
+ // Resolve the whole symlink chain so we end up at the real file inside
161
+ // ~/.local/share/claude/versions/<X.Y.Z>. resolveSymlinkChain uses lstat
162
+ // at every hop — using statSync here would follow the symlink and report
163
+ // isSymbolicLink() === false, which is the bug the previous version had:
164
+ // patched copies ended up next to the symlink (~/.local/bin/) instead of
165
+ // alongside the original under versions/.
166
+ const sourceFile = resolveSymlinkChain(CLAUDE_SYMLINK);
167
+ if (!sourceFile) {
150
168
  return { ok: false, error: `Could not resolve a real file from ${CLAUDE_SYMLINK}` };
151
169
  }
152
170
 
153
- // The symlink itself points into versions/<X.Y.Z>. We patch a sibling, not the original.
154
- // statSync on a symlink follows by default; resolve via readlinkSync if symlink.
155
- let sourceFile = source;
156
- try {
157
- const lstat = statSync(source, { throwIfNoEntry: false });
158
- if (lstat?.isSymbolicLink?.()) {
159
- const { readlinkSync } = await import("node:fs");
160
- const link = readlinkSync(source);
161
- sourceFile = link.startsWith("/") ? link : join(dirname(source), link);
162
- }
163
- } catch { /* fall through, use source */ }
164
-
165
- // Strip any trailing ".patched" if we somehow point at a patched file already.
171
+ // If the symlink chain already terminates at a .patched file, the base
172
+ // file (unpatched original) sits next to it. We patch a sibling of the
173
+ // unpatched original — never modifying it in place.
166
174
  const baseFile = sourceFile.replace(/\.patched$/, "");
167
175
  const patchedPath = `${baseFile}.patched`;
168
176
 
@@ -2,6 +2,7 @@ import { vi } from "vitest";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
+ import { _resetForTest as resetSettings } from "./settings-manager.js";
5
6
 
6
7
  // ─── Hoisted mocks ──────────────────────────────────────────────────────────
7
8
 
@@ -149,7 +150,11 @@ beforeEach(() => {
149
150
  delete process.env.COMPANION_FORCE_BYPASS_IN_CONTAINER;
150
151
  // Default to stdio for most tests; WS launcher behavior is covered explicitly below.
151
152
  process.env.COMPANION_CODEX_TRANSPORT = "stdio";
153
+ // Isolate settings: cli-launcher reads claudeBridgeMode at spawn time, and
154
+ // we want every test to start from defaults regardless of the user's real
155
+ // settings.json. Point at a temp path so persistence doesn't leak.
152
156
  tempDir = mkdtempSync(join(tmpdir(), "launcher-test-"));
157
+ resetSettings(join(tempDir, "settings.json"));
153
158
  store = new SessionStore(tempDir);
154
159
  launcher = new CliLauncher(3456);
155
160
  launcher.setStore(store);