@hellcoder/companion 0.116.1-preview.20260814042234.2d329a2 → 0.117.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/dist/assets/{AgentsPage-BAalV1v6.js → AgentsPage-DJIGGU86.js} +1 -1
- package/dist/assets/{CronManager-B2Vo1YmB.js → CronManager-D7aAMVgs.js} +1 -1
- package/dist/assets/{DashboardPage-BOvDiP1W.js → DashboardPage-Bporj7Vr.js} +1 -1
- package/dist/assets/{IntegrationsPage-kOXLH5KU.js → IntegrationsPage-BSY0-fsO.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-H-uUJ-ub.js → LinearOAuthSettingsPage-Cl5dP05g.js} +1 -1
- package/dist/assets/{LinearSettingsPage-CD_x4AGr.js → LinearSettingsPage-dwMpnpxL.js} +1 -1
- package/dist/assets/{MagicUIDashboard-9Jgiv4rb.js → MagicUIDashboard-Bpa4AupX.js} +90 -4
- package/dist/assets/{MagicUIView-BLEcS9XO.js → MagicUIView-BoHMBELi.js} +1 -1
- package/dist/assets/{Playground-QLrd9q16.js → Playground-By2aXWQF.js} +5 -5
- package/dist/assets/{PromptsPage-11eJ6ak7.js → PromptsPage-DVv0Qn3d.js} +1 -1
- package/dist/assets/{RunsPage-Bz3udIL2.js → RunsPage-bu3TZ6ai.js} +1 -1
- package/dist/assets/{SandboxManager-Dv47pbC6.js → SandboxManager-CDjGbD5N.js} +1 -1
- package/dist/assets/{SettingsPage-C_2ftG-C.js → SettingsPage-D0v_BZTU.js} +1 -1
- package/dist/assets/{TailscalePage-DzMX_7XA.js → TailscalePage-B8SJQpMF.js} +1 -1
- package/dist/assets/index-BqOdTpct.css +1 -0
- package/dist/assets/index-DqIZ_Ol5.js +140 -0
- package/dist/assets/{sw-register-CarYQEeI.js → sw-register-CvyRJNug.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/linear-agent-bridge.ts +0 -1
- package/server/magic-ui-ops.test.ts +59 -0
- package/server/magic-ui-ops.ts +29 -0
- package/server/magic-ui-types.ts +26 -2
- package/server/magic-ui-watcher.test.ts +85 -0
- package/server/magic-ui-watcher.ts +10 -2
- package/server/routes/system-routes.test.ts +401 -0
- package/server/routes/system-routes.ts +10 -0
- package/server/system-disk.test.ts +48 -0
- package/server/system-disk.ts +55 -0
- package/server/system-memory.test.ts +32 -0
- package/server/system-memory.ts +32 -0
- package/dist/assets/index-DSCRqqpK.css +0 -1
- package/dist/assets/index-DspbwgSL.js +0 -140
|
@@ -30,6 +30,55 @@ vi.mock("../service.js", () => ({
|
|
|
30
30
|
refreshServiceDefinition: vi.fn(),
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
+
// ─── Mock claude-compat checker ────────────────────────────────────────────
|
|
34
|
+
// The compat routes gate the post-2.1.121 --sdk-url lockdown workarounds.
|
|
35
|
+
// Everything here touches the real Claude binary on disk, so it is mocked
|
|
36
|
+
// wholesale — these tests exercise the route logic, never the filesystem.
|
|
37
|
+
vi.mock("../claude-compat-checker.js", () => ({
|
|
38
|
+
checkCompat: vi.fn(async () => {}),
|
|
39
|
+
getCompatState: vi.fn(() => ({
|
|
40
|
+
installedVersion: "2.1.130",
|
|
41
|
+
installedPath: "/usr/local/bin/claude",
|
|
42
|
+
isIncompatible: true,
|
|
43
|
+
isPatched: false,
|
|
44
|
+
availableKnownGood: ["2.1.119", "2.1.120"],
|
|
45
|
+
suggestedPinTarget: "2.1.120",
|
|
46
|
+
lastChecked: 0,
|
|
47
|
+
error: null,
|
|
48
|
+
})),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// ─── Mock claude-patcher ───────────────────────────────────────────────────
|
|
52
|
+
vi.mock("../claude-patcher.js", () => ({
|
|
53
|
+
pinToVersion: vi.fn(async () => ({ ok: true })),
|
|
54
|
+
patchBinary: vi.fn(async () => ({
|
|
55
|
+
ok: true,
|
|
56
|
+
patchedPath: "/usr/local/bin/claude",
|
|
57
|
+
replacements: 3,
|
|
58
|
+
})),
|
|
59
|
+
unpatch: vi.fn(async () => ({ ok: true, target: "2.1.130" })),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// ─── Mock settings-manager ─────────────────────────────────────────────────
|
|
63
|
+
vi.mock("../settings-manager.js", () => ({
|
|
64
|
+
getSettings: vi.fn(() => ({
|
|
65
|
+
claudeBridgeMode: "none",
|
|
66
|
+
claudeBridgeIngressUrl: "",
|
|
67
|
+
claudeCompatBannerDismissedVersion: "",
|
|
68
|
+
dockerAutoUpdate: false,
|
|
69
|
+
})),
|
|
70
|
+
updateSettings: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// ─── Mock cli-ingress-server ───────────────────────────────────────────────
|
|
74
|
+
// Starting the real one binds a TLS listener.
|
|
75
|
+
vi.mock("../cli-ingress-server.js", () => ({
|
|
76
|
+
startCliIngressServer: vi.fn(async () => ({
|
|
77
|
+
urlPrefix: "wss://[::1]:8443",
|
|
78
|
+
stop: vi.fn(),
|
|
79
|
+
})),
|
|
80
|
+
}));
|
|
81
|
+
|
|
33
82
|
import { Hono } from "hono";
|
|
34
83
|
import { getUsageLimits } from "../usage-limits.js";
|
|
35
84
|
import {
|
|
@@ -39,6 +88,9 @@ import {
|
|
|
39
88
|
setUpdateInProgress,
|
|
40
89
|
} from "../update-checker.js";
|
|
41
90
|
import { registerSystemRoutes } from "./system-routes.js";
|
|
91
|
+
import { checkCompat, getCompatState } from "../claude-compat-checker.js";
|
|
92
|
+
import { pinToVersion, patchBinary, unpatch } from "../claude-patcher.js";
|
|
93
|
+
import { getSettings, updateSettings } from "../settings-manager.js";
|
|
42
94
|
|
|
43
95
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
44
96
|
|
|
@@ -75,8 +127,39 @@ let launcher: ReturnType<typeof createMockLauncher>;
|
|
|
75
127
|
let wsBridge: ReturnType<typeof createMockWsBridge>;
|
|
76
128
|
let terminalManager: ReturnType<typeof createMockTerminalManager>;
|
|
77
129
|
|
|
130
|
+
/** Default compat state: an incompatible CLI with a known-good pin target. */
|
|
131
|
+
function defaultCompatState() {
|
|
132
|
+
return {
|
|
133
|
+
installedVersion: "2.1.130",
|
|
134
|
+
installedPath: "/usr/local/bin/claude",
|
|
135
|
+
isIncompatible: true,
|
|
136
|
+
isPatched: false,
|
|
137
|
+
availableKnownGood: ["2.1.119", "2.1.120"],
|
|
138
|
+
suggestedPinTarget: "2.1.120",
|
|
139
|
+
lastChecked: 0,
|
|
140
|
+
error: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
78
144
|
beforeEach(() => {
|
|
145
|
+
// clearAllMocks() resets call history but NOT implementations, so a
|
|
146
|
+
// mockReturnValue set inside one test leaks into every later one. Restoring
|
|
147
|
+
// the defaults here keeps the suite order-independent.
|
|
79
148
|
vi.clearAllMocks();
|
|
149
|
+
vi.mocked(getCompatState).mockReturnValue(defaultCompatState() as any);
|
|
150
|
+
vi.mocked(pinToVersion).mockResolvedValue({ ok: true } as any);
|
|
151
|
+
vi.mocked(patchBinary).mockResolvedValue({
|
|
152
|
+
ok: true,
|
|
153
|
+
patchedPath: "/usr/local/bin/claude",
|
|
154
|
+
replacements: 3,
|
|
155
|
+
} as any);
|
|
156
|
+
vi.mocked(unpatch).mockResolvedValue({ ok: true, target: "2.1.130" } as any);
|
|
157
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
158
|
+
claudeBridgeMode: "none",
|
|
159
|
+
claudeBridgeIngressUrl: "",
|
|
160
|
+
claudeCompatBannerDismissedVersion: "",
|
|
161
|
+
dockerAutoUpdate: false,
|
|
162
|
+
} as any);
|
|
80
163
|
|
|
81
164
|
launcher = createMockLauncher();
|
|
82
165
|
wsBridge = createMockWsBridge();
|
|
@@ -635,3 +718,321 @@ describe("POST /api/sessions/:id/message", () => {
|
|
|
635
718
|
expect(json2.error).toMatch(/content/i);
|
|
636
719
|
});
|
|
637
720
|
});
|
|
721
|
+
|
|
722
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
723
|
+
// GET /api/system/memory and GET /api/system/disk
|
|
724
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
725
|
+
|
|
726
|
+
describe("GET /api/system/memory", () => {
|
|
727
|
+
it("returns a memory snapshot including swap fields", async () => {
|
|
728
|
+
const res = await app.request("/api/system/memory");
|
|
729
|
+
expect(res.status).toBe(200);
|
|
730
|
+
const json = await res.json();
|
|
731
|
+
|
|
732
|
+
expect(json.total_bytes).toBeGreaterThan(0);
|
|
733
|
+
expect(json.used_bytes).toBeLessThanOrEqual(json.total_bytes);
|
|
734
|
+
// Swap must always be present, even on a host with none configured —
|
|
735
|
+
// the UI keys off swap_total_bytes === 0 to hide the meter.
|
|
736
|
+
expect(typeof json.swap_total_bytes).toBe("number");
|
|
737
|
+
expect(typeof json.swap_used_bytes).toBe("number");
|
|
738
|
+
expect(typeof json.swap_used_percent).toBe("number");
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe("GET /api/system/disk", () => {
|
|
743
|
+
it("returns a disk snapshot for the Companion data volume", async () => {
|
|
744
|
+
const res = await app.request("/api/system/disk");
|
|
745
|
+
expect(res.status).toBe(200);
|
|
746
|
+
const json = await res.json();
|
|
747
|
+
|
|
748
|
+
// getSystemDisk returns null when statfs is unavailable; the route passes
|
|
749
|
+
// that through as JSON null rather than an empty 204 body.
|
|
750
|
+
if (json === null) return;
|
|
751
|
+
|
|
752
|
+
expect(json.total_bytes).toBeGreaterThan(0);
|
|
753
|
+
expect(json.used_bytes).toBe(json.total_bytes - json.available_bytes);
|
|
754
|
+
expect(json.used_percent).toBeGreaterThanOrEqual(0);
|
|
755
|
+
expect(json.used_percent).toBeLessThanOrEqual(100);
|
|
756
|
+
expect(typeof json.path).toBe("string");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("always responds 200 with a JSON body, never an empty 204", async () => {
|
|
760
|
+
// The browser client calls res.json() unconditionally — an empty body
|
|
761
|
+
// would throw and log a spurious API failure on every poll.
|
|
762
|
+
const res = await app.request("/api/system/disk");
|
|
763
|
+
expect(res.status).toBe(200);
|
|
764
|
+
await expect(res.json()).resolves.not.toThrow();
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
769
|
+
// POST /api/update — guard branches only
|
|
770
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
771
|
+
|
|
772
|
+
// The success path spawns `bun install -g` and calls process.exit(), so these
|
|
773
|
+
// tests deliberately cover only the three refusal branches.
|
|
774
|
+
describe("POST /api/update (guards)", () => {
|
|
775
|
+
it("returns 400 when not running as a service", async () => {
|
|
776
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
777
|
+
currentVersion: "1.0.0", latestVersion: "1.1.0", lastChecked: 0,
|
|
778
|
+
isServiceMode: false, checking: false, updateInProgress: false, channel: "stable",
|
|
779
|
+
} as any);
|
|
780
|
+
|
|
781
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
782
|
+
expect(res.status).toBe(400);
|
|
783
|
+
expect((await res.json()).error).toMatch(/service mode/i);
|
|
784
|
+
expect(setUpdateInProgress).not.toHaveBeenCalled();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("returns 400 when no update is available", async () => {
|
|
788
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
789
|
+
currentVersion: "1.0.0", latestVersion: "1.0.0", lastChecked: 0,
|
|
790
|
+
isServiceMode: true, checking: false, updateInProgress: false, channel: "stable",
|
|
791
|
+
} as any);
|
|
792
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(false);
|
|
793
|
+
|
|
794
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
795
|
+
expect(res.status).toBe(400);
|
|
796
|
+
expect((await res.json()).error).toMatch(/no update/i);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("returns 409 when an update is already in progress", async () => {
|
|
800
|
+
vi.mocked(getUpdateState).mockReturnValue({
|
|
801
|
+
currentVersion: "1.0.0", latestVersion: "1.1.0", lastChecked: 0,
|
|
802
|
+
isServiceMode: true, checking: false, updateInProgress: true, channel: "stable",
|
|
803
|
+
} as any);
|
|
804
|
+
vi.mocked(isUpdateAvailable).mockReturnValue(true);
|
|
805
|
+
|
|
806
|
+
const res = await app.request("/api/update", { method: "POST" });
|
|
807
|
+
expect(res.status).toBe(409);
|
|
808
|
+
expect((await res.json()).error).toMatch(/already in progress/i);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
813
|
+
// Terminal routes
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
815
|
+
|
|
816
|
+
describe("terminal routes", () => {
|
|
817
|
+
it("GET /api/terminal reports inactive when no terminal exists", async () => {
|
|
818
|
+
terminalManager.getInfo.mockReturnValue(null);
|
|
819
|
+
const res = await app.request("/api/terminal");
|
|
820
|
+
expect(res.status).toBe(200);
|
|
821
|
+
expect(await res.json()).toEqual({ active: false });
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("GET /api/terminal returns the terminal id and cwd when active", async () => {
|
|
825
|
+
terminalManager.getInfo.mockReturnValue({ id: "t-1", cwd: "/repo" });
|
|
826
|
+
const res = await app.request("/api/terminal?terminalId=t-1");
|
|
827
|
+
expect(await res.json()).toEqual({ active: true, terminalId: "t-1", cwd: "/repo" });
|
|
828
|
+
expect(terminalManager.getInfo).toHaveBeenCalledWith("t-1");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("POST /api/terminal/spawn requires a cwd", async () => {
|
|
832
|
+
const res = await app.request("/api/terminal/spawn", {
|
|
833
|
+
method: "POST",
|
|
834
|
+
headers: { "Content-Type": "application/json" },
|
|
835
|
+
body: JSON.stringify({}),
|
|
836
|
+
});
|
|
837
|
+
expect(res.status).toBe(400);
|
|
838
|
+
expect((await res.json()).error).toMatch(/cwd/i);
|
|
839
|
+
expect(terminalManager.spawn).not.toHaveBeenCalled();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("POST /api/terminal/spawn passes dimensions and container through", async () => {
|
|
843
|
+
const res = await app.request("/api/terminal/spawn", {
|
|
844
|
+
method: "POST",
|
|
845
|
+
headers: { "Content-Type": "application/json" },
|
|
846
|
+
body: JSON.stringify({ cwd: "/repo", cols: 120, rows: 40, containerId: "c-1" }),
|
|
847
|
+
});
|
|
848
|
+
expect(res.status).toBe(200);
|
|
849
|
+
expect(await res.json()).toEqual({ terminalId: "terminal-123" });
|
|
850
|
+
expect(terminalManager.spawn).toHaveBeenCalledWith("/repo", 120, 40, { containerId: "c-1" });
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("POST /api/terminal/kill requires a terminalId", async () => {
|
|
854
|
+
// Covers both a missing body and a whitespace-only id.
|
|
855
|
+
const res1 = await app.request("/api/terminal/kill", { method: "POST" });
|
|
856
|
+
expect(res1.status).toBe(400);
|
|
857
|
+
|
|
858
|
+
const res2 = await app.request("/api/terminal/kill", {
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: { "Content-Type": "application/json" },
|
|
861
|
+
body: JSON.stringify({ terminalId: " " }),
|
|
862
|
+
});
|
|
863
|
+
expect(res2.status).toBe(400);
|
|
864
|
+
expect(terminalManager.kill).not.toHaveBeenCalled();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("POST /api/terminal/kill kills the named terminal", async () => {
|
|
868
|
+
const res = await app.request("/api/terminal/kill", {
|
|
869
|
+
method: "POST",
|
|
870
|
+
headers: { "Content-Type": "application/json" },
|
|
871
|
+
body: JSON.stringify({ terminalId: "t-1" }),
|
|
872
|
+
});
|
|
873
|
+
expect(res.status).toBe(200);
|
|
874
|
+
expect(terminalManager.kill).toHaveBeenCalledWith("t-1");
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
879
|
+
// Claude compatibility routes (--sdk-url lockdown workarounds)
|
|
880
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
881
|
+
|
|
882
|
+
describe("GET /api/claude-compat", () => {
|
|
883
|
+
it("refreshes when the cached state is stale, then returns the payload", async () => {
|
|
884
|
+
// lastChecked === 0 means "never checked" — must trigger a refresh.
|
|
885
|
+
const res = await app.request("/api/claude-compat");
|
|
886
|
+
expect(res.status).toBe(200);
|
|
887
|
+
expect(checkCompat).toHaveBeenCalled();
|
|
888
|
+
|
|
889
|
+
const json = await res.json();
|
|
890
|
+
expect(json.installedVersion).toBe("2.1.130");
|
|
891
|
+
expect(json.isIncompatible).toBe(true);
|
|
892
|
+
expect(json.suggestedPinTarget).toBe("2.1.120");
|
|
893
|
+
// Settings-derived fields are merged into the same payload.
|
|
894
|
+
expect(json.bridgeMode).toBe("none");
|
|
895
|
+
expect(json.bannerDismissedVersion).toBe("");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("skips the refresh when the cached state is fresh", async () => {
|
|
899
|
+
vi.mocked(getCompatState).mockReturnValue({
|
|
900
|
+
installedVersion: "2.1.130", installedPath: "/usr/local/bin/claude",
|
|
901
|
+
isIncompatible: false, isPatched: false, availableKnownGood: [],
|
|
902
|
+
suggestedPinTarget: "", lastChecked: Date.now(), error: null,
|
|
903
|
+
} as any);
|
|
904
|
+
|
|
905
|
+
await app.request("/api/claude-compat");
|
|
906
|
+
expect(checkCompat).not.toHaveBeenCalled();
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
describe("POST /api/claude-compat/refresh", () => {
|
|
911
|
+
it("always re-checks and returns the fresh payload", async () => {
|
|
912
|
+
const res = await app.request("/api/claude-compat/refresh", { method: "POST" });
|
|
913
|
+
expect(res.status).toBe(200);
|
|
914
|
+
expect(checkCompat).toHaveBeenCalled();
|
|
915
|
+
expect((await res.json()).installedVersion).toBe("2.1.130");
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
describe("POST /api/claude-compat/pin", () => {
|
|
920
|
+
it("pins to the suggested target and disables patched bridge mode", async () => {
|
|
921
|
+
const res = await app.request("/api/claude-compat/pin", { method: "POST" });
|
|
922
|
+
expect(res.status).toBe(200);
|
|
923
|
+
expect(pinToVersion).toHaveBeenCalledWith("2.1.120");
|
|
924
|
+
|
|
925
|
+
// Pinning returns to a non-validator binary, so the bridge must be turned
|
|
926
|
+
// off — otherwise the CLI keeps being pointed at wss://[::1].
|
|
927
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
928
|
+
claudeBridgeMode: "none",
|
|
929
|
+
claudeBridgeIngressUrl: "",
|
|
930
|
+
});
|
|
931
|
+
expect((await res.json()).pinnedTo).toBe("2.1.120");
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("honours an explicit version from the body", async () => {
|
|
935
|
+
await app.request("/api/claude-compat/pin", {
|
|
936
|
+
method: "POST",
|
|
937
|
+
headers: { "Content-Type": "application/json" },
|
|
938
|
+
body: JSON.stringify({ version: "2.1.119" }),
|
|
939
|
+
});
|
|
940
|
+
expect(pinToVersion).toHaveBeenCalledWith("2.1.119");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("returns 400 when no known-good version is available to pin to", async () => {
|
|
944
|
+
vi.mocked(getCompatState).mockReturnValue({
|
|
945
|
+
installedVersion: "2.1.130", installedPath: "/usr/local/bin/claude",
|
|
946
|
+
isIncompatible: true, isPatched: false, availableKnownGood: [],
|
|
947
|
+
suggestedPinTarget: "", lastChecked: 0, error: null,
|
|
948
|
+
} as any);
|
|
949
|
+
|
|
950
|
+
const res = await app.request("/api/claude-compat/pin", { method: "POST" });
|
|
951
|
+
expect(res.status).toBe(400);
|
|
952
|
+
expect((await res.json()).error).toMatch(/known-good/i);
|
|
953
|
+
expect(pinToVersion).not.toHaveBeenCalled();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("surfaces a pin failure as 400", async () => {
|
|
957
|
+
vi.mocked(pinToVersion).mockResolvedValue({ ok: false, error: "download failed" } as any);
|
|
958
|
+
const res = await app.request("/api/claude-compat/pin", { method: "POST" });
|
|
959
|
+
expect(res.status).toBe(400);
|
|
960
|
+
expect((await res.json()).error).toBe("download failed");
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
describe("POST /api/claude-compat/patch", () => {
|
|
965
|
+
it("patches the binary, starts the ingress listener and persists the URL", async () => {
|
|
966
|
+
const res = await app.request("/api/claude-compat/patch", { method: "POST" });
|
|
967
|
+
expect(res.status).toBe(200);
|
|
968
|
+
expect(patchBinary).toHaveBeenCalled();
|
|
969
|
+
|
|
970
|
+
const json = await res.json();
|
|
971
|
+
expect(json.replacements).toBe(3);
|
|
972
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
973
|
+
claudeBridgeMode: "patched",
|
|
974
|
+
claudeBridgeIngressUrl: "wss://[::1]:8443",
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("returns 400 when the binary cannot be patched", async () => {
|
|
979
|
+
vi.mocked(patchBinary).mockResolvedValue({ ok: false, error: "unknown layout" } as any);
|
|
980
|
+
const res = await app.request("/api/claude-compat/patch", { method: "POST" });
|
|
981
|
+
expect(res.status).toBe(400);
|
|
982
|
+
expect((await res.json()).error).toBe("unknown layout");
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
describe("POST /api/claude-compat/unpatch", () => {
|
|
987
|
+
it("restores the binary and clears bridge settings", async () => {
|
|
988
|
+
const res = await app.request("/api/claude-compat/unpatch", { method: "POST" });
|
|
989
|
+
expect(res.status).toBe(200);
|
|
990
|
+
expect(unpatch).toHaveBeenCalled();
|
|
991
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
992
|
+
claudeBridgeMode: "none",
|
|
993
|
+
claudeBridgeIngressUrl: "",
|
|
994
|
+
});
|
|
995
|
+
expect((await res.json()).target).toBe("2.1.130");
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it("returns 400 when unpatching fails", async () => {
|
|
999
|
+
vi.mocked(unpatch).mockResolvedValue({ ok: false, error: "no backup" } as any);
|
|
1000
|
+
const res = await app.request("/api/claude-compat/unpatch", { method: "POST" });
|
|
1001
|
+
expect(res.status).toBe(400);
|
|
1002
|
+
expect((await res.json()).error).toBe("no backup");
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
describe("POST /api/claude-compat/dismiss-banner", () => {
|
|
1007
|
+
it("records the explicitly supplied version", async () => {
|
|
1008
|
+
const res = await app.request("/api/claude-compat/dismiss-banner", {
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
headers: { "Content-Type": "application/json" },
|
|
1011
|
+
body: JSON.stringify({ version: "2.1.131" }),
|
|
1012
|
+
});
|
|
1013
|
+
expect(res.status).toBe(200);
|
|
1014
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
1015
|
+
claudeCompatBannerDismissedVersion: "2.1.131",
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("falls back to the currently installed version", async () => {
|
|
1020
|
+
const res = await app.request("/api/claude-compat/dismiss-banner", { method: "POST" });
|
|
1021
|
+
expect(res.status).toBe(200);
|
|
1022
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
1023
|
+
claudeCompatBannerDismissedVersion: "2.1.130",
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("returns 400 when there is no version to record", async () => {
|
|
1028
|
+
vi.mocked(getCompatState).mockReturnValue({
|
|
1029
|
+
installedVersion: null, installedPath: null, isIncompatible: false,
|
|
1030
|
+
isPatched: false, availableKnownGood: [], suggestedPinTarget: "",
|
|
1031
|
+
lastChecked: 0, error: null,
|
|
1032
|
+
} as any);
|
|
1033
|
+
|
|
1034
|
+
const res = await app.request("/api/claude-compat/dismiss-banner", { method: "POST" });
|
|
1035
|
+
expect(res.status).toBe(400);
|
|
1036
|
+
expect((await res.json()).error).toMatch(/version/i);
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
@@ -4,6 +4,7 @@ import type { WsBridge } from "../ws-bridge.js";
|
|
|
4
4
|
import type { TerminalManager } from "../terminal-manager.js";
|
|
5
5
|
import { getUsageLimits } from "../usage-limits.js";
|
|
6
6
|
import { getSystemMemory } from "../system-memory.js";
|
|
7
|
+
import { getSystemDisk } from "../system-disk.js";
|
|
7
8
|
import {
|
|
8
9
|
getUpdateState,
|
|
9
10
|
checkForUpdate,
|
|
@@ -49,6 +50,15 @@ export function registerSystemRoutes(
|
|
|
49
50
|
return c.json(getSystemMemory());
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
// Free space on the volume holding COMPANION_HOME. Backed by a single
|
|
54
|
+
// statfs(2) call, so it is cheap enough to poll without caching.
|
|
55
|
+
api.get("/system/disk", (c) => {
|
|
56
|
+
// JSON null rather than 204: the browser client calls res.json()
|
|
57
|
+
// unconditionally, and an empty body would throw and register a spurious
|
|
58
|
+
// API failure on every poll.
|
|
59
|
+
return c.json(getSystemDisk());
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
api.get("/sessions/:id/usage-limits", async (c) => {
|
|
53
63
|
const sessionId = c.req.param("id");
|
|
54
64
|
const session = deps.wsBridge.getSession(sessionId);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getSystemDisk } from "./system-disk.js";
|
|
3
|
+
|
|
4
|
+
// getSystemDisk reads the real filesystem via statfs(2), so exact byte counts
|
|
5
|
+
// are machine-dependent. The invariants below must hold on any host where
|
|
6
|
+
// statfs succeeds. On a host without statfs the function returns null, which
|
|
7
|
+
// every test tolerates — the meter is best-effort by design.
|
|
8
|
+
describe("getSystemDisk", () => {
|
|
9
|
+
it("returns a coherent snapshot with sane invariants", () => {
|
|
10
|
+
const d = getSystemDisk();
|
|
11
|
+
if (!d) return; // statfs unavailable — nothing to assert
|
|
12
|
+
|
|
13
|
+
// A mounted filesystem always has positive capacity.
|
|
14
|
+
expect(d.total_bytes).toBeGreaterThan(0);
|
|
15
|
+
|
|
16
|
+
expect(d.used_bytes).toBeGreaterThanOrEqual(0);
|
|
17
|
+
expect(d.available_bytes).toBeGreaterThanOrEqual(0);
|
|
18
|
+
expect(d.used_bytes).toBeLessThanOrEqual(d.total_bytes);
|
|
19
|
+
expect(d.available_bytes).toBeLessThanOrEqual(d.total_bytes);
|
|
20
|
+
|
|
21
|
+
// used is derived as total - available (never bfree), so the identity
|
|
22
|
+
// must hold exactly. This is the guard against a future refactor
|
|
23
|
+
// switching to bfree and silently under-reporting usage.
|
|
24
|
+
expect(d.used_bytes).toBe(Math.max(0, d.total_bytes - d.available_bytes));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reports used_percent in 0–100 rounded to one decimal", () => {
|
|
28
|
+
const d = getSystemDisk();
|
|
29
|
+
if (!d) return;
|
|
30
|
+
expect(d.used_percent).toBeGreaterThanOrEqual(0);
|
|
31
|
+
expect(d.used_percent).toBeLessThanOrEqual(100);
|
|
32
|
+
expect(Math.round(d.used_percent * 10)).toBe(d.used_percent * 10);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reports the Companion data dir as the measured path", () => {
|
|
36
|
+
const d = getSystemDisk();
|
|
37
|
+
if (!d) return;
|
|
38
|
+
// The meter must describe the volume that session/recording files grow
|
|
39
|
+
// into, which is not necessarily the one holding "/".
|
|
40
|
+
expect(d.path).toBeTruthy();
|
|
41
|
+
expect(typeof d.path).toBe("string");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("never throws, even though the underlying syscall can fail", () => {
|
|
45
|
+
// The route calls this on every poll with no try/catch of its own.
|
|
46
|
+
expect(() => getSystemDisk()).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { statfsSync } from "node:fs";
|
|
2
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
export interface SystemDiskInfo {
|
|
5
|
+
/** Total size of the filesystem holding COMPANION_HOME, in bytes. */
|
|
6
|
+
total_bytes: number;
|
|
7
|
+
/** Space in use (total - available), in bytes. */
|
|
8
|
+
used_bytes: number;
|
|
9
|
+
/** Space available to this (unprivileged) user, in bytes. */
|
|
10
|
+
available_bytes: number;
|
|
11
|
+
/** Used percentage of total, 0–100, rounded to one decimal. */
|
|
12
|
+
used_percent: number;
|
|
13
|
+
/** Path the figures were measured for. */
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Snapshot of free space on the filesystem that holds the Companion data
|
|
19
|
+
* directory — that's the volume sessions, recordings and logs actually grow
|
|
20
|
+
* into, which is not necessarily the one holding "/".
|
|
21
|
+
*
|
|
22
|
+
* Deliberately uses statfs(2) rather than shelling out to `df`, and never
|
|
23
|
+
* walks the tree the way `du` would: this is a single syscall against the
|
|
24
|
+
* already-in-memory superblock (~0.1ms, no disk I/O), so it is safe to call
|
|
25
|
+
* on a poll interval and needs no caching layer.
|
|
26
|
+
*
|
|
27
|
+
* We report `bavail` (blocks free to unprivileged users) rather than `bfree`,
|
|
28
|
+
* which excludes the root-reserved reserve. That mirrors how getSystemMemory()
|
|
29
|
+
* prefers MemAvailable over MemFree: both answer "how much can I actually
|
|
30
|
+
* still use" rather than "how much is technically unallocated". It is also why
|
|
31
|
+
* this can read a percent or two above `df` for the same filesystem.
|
|
32
|
+
*/
|
|
33
|
+
export function getSystemDisk(): SystemDiskInfo | null {
|
|
34
|
+
try {
|
|
35
|
+
const stats = statfsSync(COMPANION_HOME);
|
|
36
|
+
// bsize is the preferred I/O block size that blocks/bavail are counted in.
|
|
37
|
+
const total = stats.blocks * stats.bsize;
|
|
38
|
+
const available = stats.bavail * stats.bsize;
|
|
39
|
+
const used = Math.max(0, total - available);
|
|
40
|
+
const used_percent =
|
|
41
|
+
total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
42
|
+
return {
|
|
43
|
+
total_bytes: total,
|
|
44
|
+
used_bytes: used,
|
|
45
|
+
available_bytes: available,
|
|
46
|
+
used_percent,
|
|
47
|
+
path: COMPANION_HOME,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
// statfs is unavailable (very old runtime) or the path does not exist yet
|
|
51
|
+
// on a first run. The meter is best-effort — the route returns 204 and the
|
|
52
|
+
// UI renders nothing rather than showing a bogus zeroed bar.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -28,4 +28,36 @@ describe("getSystemMemory", () => {
|
|
|
28
28
|
// At most one decimal place.
|
|
29
29
|
expect(Math.round(m.used_percent * 10)).toBe(m.used_percent * 10);
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
// Swap is read from the same /proc/meminfo snapshot as RAM. A host may have
|
|
33
|
+
// no swap at all, in which case every swap field must be 0 — the UI keys off
|
|
34
|
+
// swap_total_bytes === 0 to hide the meter entirely.
|
|
35
|
+
it("returns coherent swap figures, or zeroes when swap is absent", () => {
|
|
36
|
+
const m = getSystemMemory();
|
|
37
|
+
|
|
38
|
+
expect(m.swap_total_bytes).toBeGreaterThanOrEqual(0);
|
|
39
|
+
expect(m.swap_used_bytes).toBeGreaterThanOrEqual(0);
|
|
40
|
+
|
|
41
|
+
if (m.swap_total_bytes === 0) {
|
|
42
|
+
// No swap configured (or a non-Linux host): must not report phantom use.
|
|
43
|
+
expect(m.swap_used_bytes).toBe(0);
|
|
44
|
+
expect(m.swap_used_percent).toBe(0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Used swap can never exceed the configured total, even though SwapFree
|
|
49
|
+
// can briefly exceed SwapTotal during swapoff — that's what the clamp is for.
|
|
50
|
+
expect(m.swap_used_bytes).toBeLessThanOrEqual(m.swap_total_bytes);
|
|
51
|
+
expect(m.swap_used_percent).toBeGreaterThanOrEqual(0);
|
|
52
|
+
expect(m.swap_used_percent).toBeLessThanOrEqual(100);
|
|
53
|
+
expect(Math.round(m.swap_used_percent * 10)).toBe(m.swap_used_percent * 10);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps swap independent of the RAM figures", () => {
|
|
57
|
+
// MemAvailable deliberately excludes swap, so a swapping box shows healthy
|
|
58
|
+
// RAM headroom while thrashing. Guards against a refactor that folds swap
|
|
59
|
+
// into used_bytes and hides exactly the condition we want to surface.
|
|
60
|
+
const m = getSystemMemory();
|
|
61
|
+
expect(m.used_bytes).toBe(Math.max(0, m.total_bytes - m.available_bytes));
|
|
62
|
+
});
|
|
31
63
|
});
|
package/server/system-memory.ts
CHANGED
|
@@ -10,6 +10,15 @@ export interface SystemMemoryInfo {
|
|
|
10
10
|
available_bytes: number;
|
|
11
11
|
/** Used percentage of total, 0–100, rounded to one decimal. */
|
|
12
12
|
used_percent: number;
|
|
13
|
+
/**
|
|
14
|
+
* Total swap, in bytes. 0 when no swap is configured, or on platforms where
|
|
15
|
+
* we cannot read it — callers should treat 0 as "no swap meter to show".
|
|
16
|
+
*/
|
|
17
|
+
swap_total_bytes: number;
|
|
18
|
+
/** Swap in use (total - free), in bytes. */
|
|
19
|
+
swap_used_bytes: number;
|
|
20
|
+
/** Used percentage of swap, 0–100, rounded to one decimal. 0 when no swap. */
|
|
21
|
+
swap_used_percent: number;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -25,10 +34,19 @@ function readMeminfoKb(meminfo: string, key: string): number | null {
|
|
|
25
34
|
* MemAvailable, which accounts for reclaimable page cache and therefore
|
|
26
35
|
* reflects true OOM headroom far better than os.freemem(). Everywhere else
|
|
27
36
|
* (or if /proc is unreadable) we fall back to the os module.
|
|
37
|
+
*
|
|
38
|
+
* Swap comes from the same single /proc/meminfo read, so reporting it costs
|
|
39
|
+
* nothing extra. It is worth surfacing separately rather than folding into the
|
|
40
|
+
* RAM figure: a box that has begun swapping heavily is already thrashing, and
|
|
41
|
+
* because MemAvailable excludes swap the RAM meter alone cannot show it. Swap
|
|
42
|
+
* exhaustion on top of high RAM use is the state immediately preceding the OOM
|
|
43
|
+
* killer, which is exactly what these meters exist to warn about.
|
|
28
44
|
*/
|
|
29
45
|
export function getSystemMemory(): SystemMemoryInfo {
|
|
30
46
|
let total = os.totalmem();
|
|
31
47
|
let available = os.freemem();
|
|
48
|
+
let swapTotal = 0;
|
|
49
|
+
let swapUsed = 0;
|
|
32
50
|
|
|
33
51
|
try {
|
|
34
52
|
const meminfo = readFileSync("/proc/meminfo", "utf8");
|
|
@@ -38,16 +56,30 @@ export function getSystemMemory(): SystemMemoryInfo {
|
|
|
38
56
|
total = totalKb * 1024;
|
|
39
57
|
available = availableKb * 1024;
|
|
40
58
|
}
|
|
59
|
+
const swapTotalKb = readMeminfoKb(meminfo, "SwapTotal");
|
|
60
|
+
const swapFreeKb = readMeminfoKb(meminfo, "SwapFree");
|
|
61
|
+
if (swapTotalKb !== null && swapFreeKb !== null) {
|
|
62
|
+
swapTotal = swapTotalKb * 1024;
|
|
63
|
+
// Clamp: SwapFree can momentarily exceed SwapTotal mid-swapoff.
|
|
64
|
+
swapUsed = Math.max(0, swapTotal - swapFreeKb * 1024);
|
|
65
|
+
}
|
|
41
66
|
} catch {
|
|
42
67
|
// Not Linux, or /proc/meminfo unavailable — keep the os module values.
|
|
68
|
+
// The os module exposes no swap figures, so swap stays 0 (= "unknown",
|
|
69
|
+
// rendered as no meter) rather than being reported as 0-bytes-used.
|
|
43
70
|
}
|
|
44
71
|
|
|
45
72
|
const used = Math.max(0, total - available);
|
|
46
73
|
const used_percent = total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
74
|
+
const swap_used_percent =
|
|
75
|
+
swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 1000) / 10 : 0;
|
|
47
76
|
return {
|
|
48
77
|
total_bytes: total,
|
|
49
78
|
used_bytes: used,
|
|
50
79
|
available_bytes: available,
|
|
51
80
|
used_percent,
|
|
81
|
+
swap_total_bytes: swapTotal,
|
|
82
|
+
swap_used_bytes: swapUsed,
|
|
83
|
+
swap_used_percent,
|
|
52
84
|
};
|
|
53
85
|
}
|