@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.
|
|
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
|
|
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>.
|
|
89
|
-
*
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
132
|
-
* marker
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
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
|
}
|
package/server/claude-patcher.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
existsSync,
|
|
21
21
|
mkdirSync,
|
|
22
22
|
readFileSync,
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
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);
|