@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260718-8f6aa44b

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 (80) hide show
  1. package/dist/auth/manager.js +1 -1
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +1 -1
  4. package/dist/index.js +25 -0
  5. package/dist/index.test.js +40 -10
  6. package/dist/opencode-update/service.js +150 -0
  7. package/dist/opencode-update/service.test.js +107 -0
  8. package/dist/server/__tests__/remote-proxy.test.js +64 -56
  9. package/dist/server/http-server.js +7 -1
  10. package/dist/server/remote-proxy.js +68 -98
  11. package/dist/server/routes/opencode-update.js +37 -0
  12. package/dist/server/routes/opencode-update.test.js +34 -0
  13. package/dist/server/routes/remote-servers.js +1 -1
  14. package/dist/server/routes/workspaces.test.js +38 -0
  15. package/dist/server/tls.js +2 -2
  16. package/dist/settings/binaries.test.js +2 -7
  17. package/dist/shutdown.js +67 -59
  18. package/dist/shutdown.test.js +115 -78
  19. package/dist/ui/remote-ui.js +2 -2
  20. package/dist/usage/providers/api-key.js +3 -3
  21. package/dist/usage/providers/google.js +1 -1
  22. package/dist/usage/providers/special.js +3 -3
  23. package/dist/usage/service.js +1 -1
  24. package/dist/usage/service.test.js +1 -1
  25. package/dist/usage/shared.js +4 -4
  26. package/dist/usage/shared.test.js +1 -1
  27. package/dist/workspaces/__tests__/spawn.test.js +2 -2
  28. package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
  29. package/dist/workspaces/binary-path.test.js +10 -0
  30. package/dist/workspaces/instance-client.js +4 -3
  31. package/dist/workspaces/launch-cleanup.test.js +21 -25
  32. package/dist/workspaces/manager.js +300 -351
  33. package/dist/workspaces/manager.test.js +209 -237
  34. package/dist/workspaces/process-identity.darwin.test.js +88 -0
  35. package/dist/workspaces/process-identity.js +172 -149
  36. package/dist/workspaces/process-identity.test.js +86 -140
  37. package/dist/workspaces/runtime.js +261 -373
  38. package/dist/workspaces/runtime.test.js +222 -547
  39. package/package.json +1 -1
  40. package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-B0-DlSCT.js} +2 -2
  41. package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-CxjbNBVr.js} +2 -2
  42. package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-CLQb-6uX.js} +1 -1
  43. package/public/assets/{StatusTab-D_IE79r-.js → StatusTab-CBGAuhmp.js} +1 -1
  44. package/public/assets/{align-justify-BKS9gyRk.js → align-justify-s5mnjsP0.js} +1 -1
  45. package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-lwIMY5om.js} +1 -1
  46. package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-CDtmFe6e.js} +1 -1
  47. package/public/assets/index-6ABz_Izb.js +1 -0
  48. package/public/assets/index-BPdjM9ZI.css +1 -0
  49. package/public/assets/index-BQK8um1r.js +1 -0
  50. package/public/assets/index-BkUAI4ZY.js +1 -0
  51. package/public/assets/index-D8bkUvEd.js +2 -0
  52. package/public/assets/index-DY_sbCt9.js +1 -0
  53. package/public/assets/index-DhmUOdAI.js +1 -0
  54. package/public/assets/{index-sLe9hEfQ.js → index-DupnEKCQ.js} +1 -1
  55. package/public/assets/index-HhZzWWha.js +1 -0
  56. package/public/assets/{index-DVc4ls8c.js → index-lBAEWdAF.js} +1 -1
  57. package/public/assets/index-nVII4VJz.js +1 -0
  58. package/public/assets/index-p1Sble5V.js +1 -0
  59. package/public/assets/{loading-CDmt9OJU.js → loading-h8DvUqqc.js} +1 -1
  60. package/public/assets/main-BxUSjoWA.js +68 -0
  61. package/public/assets/{markdown-1ZLSxCFT.js → markdown-Djug_spc.js} +3 -3
  62. package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-lkUcsK65.js} +2 -2
  63. package/public/assets/{tool-call-CyXb63LH.js → tool-call-C4YH896w.js} +3 -3
  64. package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DwNZOVlY.js} +1 -1
  65. package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-BLVYxvcj.js} +1 -1
  66. package/public/index.html +4 -4
  67. package/public/loading.html +4 -4
  68. package/public/sw.js +1 -1
  69. package/dist/workspaces/launch-cleanup.js +0 -132
  70. package/public/assets/index-B3YjNCh4.js +0 -1
  71. package/public/assets/index-B3ZD2_SU.js +0 -1
  72. package/public/assets/index-Bh2ofaQo.js +0 -1
  73. package/public/assets/index-CM0c9xRZ.js +0 -1
  74. package/public/assets/index-CdDxouJl.js +0 -2
  75. package/public/assets/index-D06Y2cFq.js +0 -1
  76. package/public/assets/index-PmE51vPh.js +0 -1
  77. package/public/assets/index-Qj63IiSj.css +0 -1
  78. package/public/assets/index-VY_zwmsG.js +0 -1
  79. package/public/assets/index-qiiH7egV.js +0 -1
  80. package/public/assets/main-CTN6Wwz1.js +0 -68
@@ -26,7 +26,7 @@ export class AuthManager {
26
26
  password: init.password,
27
27
  allowBootstrapWithoutPassword: init.generateToken,
28
28
  });
29
- this.tokenManager = init.generateToken ? new TokenManager(60000) : null;
29
+ this.tokenManager = init.generateToken ? new TokenManager(60_000) : null;
30
30
  }
31
31
  isAuthEnabled() {
32
32
  return this.authEnabled;
@@ -7,7 +7,7 @@ describe("workspace search cache", () => {
7
7
  });
8
8
  it("expires cached candidates after the TTL", () => {
9
9
  const workspacePath = "/tmp/workspace";
10
- const startTime = 1000;
10
+ const startTime = 1_000;
11
11
  refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
12
12
  const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
13
13
  assert.ok(beforeExpiry);
@@ -18,12 +18,12 @@ describe("workspace search cache", () => {
18
18
  });
19
19
  it("replaces cached entries when manually refreshed", () => {
20
20
  const workspacePath = "/tmp/workspace";
21
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5000);
22
- const initial = getWorkspaceCandidates(workspacePath, 5001);
21
+ refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000);
22
+ const initial = getWorkspaceCandidates(workspacePath, 5_001);
23
23
  assert.ok(initial);
24
24
  assert.equal(initial[0].name, "file-a");
25
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6000);
26
- const refreshed = getWorkspaceCandidates(workspacePath, 6001);
25
+ refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000);
26
+ const refreshed = getWorkspaceCandidates(workspacePath, 6_001);
27
27
  assert.ok(refreshed);
28
28
  assert.equal(refreshed[0].name, "file-b");
29
29
  });
@@ -1,5 +1,5 @@
1
1
  import path from "path";
2
- export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30000;
2
+ export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000;
3
3
  const workspaceCandidateCache = new Map();
4
4
  export function getWorkspaceCandidates(rootDir, now = Date.now()) {
5
5
  const key = normalizeKey(rootDir);
package/dist/index.js CHANGED
@@ -41,10 +41,29 @@ const DEFAULT_HOST = "127.0.0.1";
41
41
  const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
42
42
  const DEFAULT_HTTPS_PORT = 9898;
43
43
  const DEFAULT_HTTP_PORT = 9899;
44
+ export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown";
44
45
  export function installShutdownSignalHandlers(source, shutdown) {
45
46
  source.on("SIGINT", () => void shutdown("SIGINT"));
46
47
  source.on("SIGTERM", () => void shutdown("SIGTERM"));
47
48
  }
49
+ export function installShutdownStdinHandler(source, shutdown) {
50
+ let buffer = "";
51
+ let requested = false;
52
+ const onData = (chunk) => {
53
+ if (requested)
54
+ return;
55
+ buffer += chunk.toString();
56
+ const lines = buffer.split(/\r?\n/);
57
+ buffer = lines.pop() ?? "";
58
+ if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND))
59
+ return;
60
+ requested = true;
61
+ source.off?.("data", onData);
62
+ source.destroy?.();
63
+ void shutdown("stdin");
64
+ };
65
+ source.on("data", onData);
66
+ }
48
67
  function parseCliOptions(argv) {
49
68
  const program = new Command()
50
69
  .name("codenomad")
@@ -431,6 +450,11 @@ async function main() {
431
450
  }
432
451
  const shutdown = createServerShutdownHandler({
433
452
  logger,
453
+ holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000); }),
454
+ setExitCode: (code) => {
455
+ process.stdin.destroy();
456
+ process.exitCode = code;
457
+ },
434
458
  shutdown: () => orchestrateServerShutdown({
435
459
  stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
436
460
  stopSidecars: () => sidecarManager.shutdown(),
@@ -451,6 +475,7 @@ async function main() {
451
475
  }, logger),
452
476
  });
453
477
  installShutdownSignalHandlers(process, shutdown);
478
+ installShutdownStdinHandler(process.stdin, shutdown);
454
479
  }
455
480
  if (path.resolve(process.argv[1] ?? "") === __filename) {
456
481
  main().catch((error) => {
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
2
4
  import { describe, it } from "node:test";
3
- import { installShutdownSignalHandlers } from "./index";
5
+ import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index";
4
6
  import { createServerShutdownHandler } from "./shutdown";
5
7
  describe("CLI shutdown signal registration", () => {
6
8
  it("routes a second process signal to forced nonzero escalation", async () => {
@@ -10,23 +12,51 @@ describe("CLI shutdown signal registration", () => {
10
12
  finishCleanup = resolve;
11
13
  });
12
14
  const exits = [];
15
+ let handled;
13
16
  const shutdown = createServerShutdownHandler({
14
17
  shutdown: () => cleanup,
15
- logger: {
16
- info: () => undefined,
17
- warn: () => undefined,
18
- error: () => undefined,
19
- },
18
+ logger: { info() { }, warn() { }, error() { } },
20
19
  forceExit: (code) => exits.push(code),
21
20
  setExitCode: () => undefined,
21
+ reportStatus: () => undefined,
22
22
  });
23
- installShutdownSignalHandlers({
24
- on: (signal, listener) => listeners.set(signal, listener),
25
- }, shutdown);
23
+ installShutdownSignalHandlers({ on: (signal, listener) => listeners.set(signal, listener) }, (signal) => (handled = shutdown(signal)));
26
24
  listeners.get("SIGINT")?.();
27
25
  listeners.get("SIGTERM")?.();
28
26
  assert.deepEqual(exits, [1]);
29
27
  finishCleanup();
30
- await cleanup;
28
+ await handled;
29
+ });
30
+ it("coalesces chunked and repeated stdin shutdown commands through the same handler", async () => {
31
+ const listeners = new Map();
32
+ const triggers = [];
33
+ let destroys = 0;
34
+ const source = {
35
+ on: (event, listener) => listeners.set(event, listener),
36
+ off: (event, listener) => {
37
+ if (listeners.get(event) === listener)
38
+ listeners.delete(event);
39
+ },
40
+ destroy: () => { destroys++; },
41
+ };
42
+ installShutdownStdinHandler(source, async (trigger) => { triggers.push(trigger); });
43
+ const listener = listeners.get("data");
44
+ listener(STDIN_SHUTDOWN_COMMAND.slice(0, 8));
45
+ listener(`${STDIN_SHUTDOWN_COMMAND.slice(8)}\n${STDIN_SHUTDOWN_COMMAND}\n`);
46
+ listener(`${STDIN_SHUTDOWN_COMMAND}\n`);
47
+ await Promise.resolve();
48
+ assert.deepEqual(triggers, ["stdin"]);
49
+ assert.equal(destroys, 1);
50
+ assert.equal(listeners.has("data"), false);
51
+ });
52
+ it("allows a real piped process to exit naturally after the shutdown command", { timeout: 5_000 }, async () => {
53
+ const moduleUrl = new URL("./index.ts", import.meta.url).href;
54
+ const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", `
55
+ import { installShutdownStdinHandler } from ${JSON.stringify(moduleUrl)}
56
+ installShutdownStdinHandler(process.stdin, async () => { process.exitCode = 0 })
57
+ `], { stdio: ["pipe", "ignore", "inherit"] });
58
+ child.stdin.end(`${STDIN_SHUTDOWN_COMMAND}\n`);
59
+ const [code] = await once(child, "exit");
60
+ assert.equal(code, 0);
31
61
  });
32
62
  });
@@ -0,0 +1,150 @@
1
+ import { fetch } from "undici";
2
+ import { BinaryResolver } from "../settings/binaries";
3
+ import { createInstanceClient } from "../workspaces/instance-client";
4
+ import { probeBinaryVersion } from "../workspaces/spawn";
5
+ import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor";
6
+ const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest";
7
+ const LATEST_VERSION_CACHE_MS = 5 * 60_000;
8
+ const UPGRADE_TIMEOUT_MS = 10 * 60_000;
9
+ const inFlightUpgrades = new Map();
10
+ export class OpenCodeUpdateError extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ this.name = "OpenCodeUpdateError";
15
+ }
16
+ }
17
+ export class OpenCodeUpdateService {
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ this.latestVersionCache = null;
21
+ }
22
+ async getStatus() {
23
+ const binary = this.deps.resolveBinary();
24
+ const currentVersion = this.readCurrentVersion(binary.path);
25
+ let latestVersion;
26
+ try {
27
+ latestVersion = await this.readLatestVersion();
28
+ }
29
+ catch (error) {
30
+ if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed")
31
+ throw error;
32
+ return {
33
+ currentVersion,
34
+ latestVersion: null,
35
+ updateAvailable: null,
36
+ canUpgrade: false,
37
+ checkError: "update_check_failed",
38
+ };
39
+ }
40
+ const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0;
41
+ const readyInstanceId = this.deps.findReadyInstanceId(binary.path);
42
+ return {
43
+ currentVersion,
44
+ latestVersion,
45
+ updateAvailable,
46
+ canUpgrade: updateAvailable && Boolean(readyInstanceId),
47
+ };
48
+ }
49
+ upgrade() {
50
+ const binary = this.deps.resolveBinary();
51
+ const existing = inFlightUpgrades.get(binary.path);
52
+ if (existing)
53
+ return existing;
54
+ const pending = this.performUpgrade(binary).finally(() => {
55
+ if (inFlightUpgrades.get(binary.path) === pending)
56
+ inFlightUpgrades.delete(binary.path);
57
+ });
58
+ inFlightUpgrades.set(binary.path, pending);
59
+ return pending;
60
+ }
61
+ async performUpgrade(binary) {
62
+ const currentVersion = this.readCurrentVersion(binary.path);
63
+ const latestVersion = await this.readLatestVersion();
64
+ if (compareVersionStrings(latestVersion, currentVersion) <= 0) {
65
+ return { success: true, version: currentVersion };
66
+ }
67
+ const instanceId = this.deps.findReadyInstanceId(binary.path);
68
+ if (!instanceId) {
69
+ throw new OpenCodeUpdateError("no_ready_instance", "No running OpenCode instance uses the configured binary");
70
+ }
71
+ try {
72
+ const result = await this.deps.upgradeInstance(instanceId, latestVersion);
73
+ if (!result.success) {
74
+ throw new OpenCodeUpdateError("upgrade_failed", result.error);
75
+ }
76
+ const installedVersion = this.readCurrentVersion(binary.path);
77
+ if (compareVersionStrings(installedVersion, latestVersion) !== 0) {
78
+ throw new OpenCodeUpdateError("upgrade_verification_failed", `OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`);
79
+ }
80
+ return { success: true, version: installedVersion };
81
+ }
82
+ catch (error) {
83
+ if (error instanceof OpenCodeUpdateError)
84
+ throw error;
85
+ throw new OpenCodeUpdateError("upgrade_failed", error instanceof Error ? error.message : "OpenCode upgrade failed");
86
+ }
87
+ }
88
+ readCurrentVersion(binaryPath) {
89
+ if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) {
90
+ throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid");
91
+ }
92
+ const result = this.deps.probeBinary(binaryPath);
93
+ const version = stripTagPrefix(result.version);
94
+ if (!result.valid || !version) {
95
+ throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version");
96
+ }
97
+ return version;
98
+ }
99
+ async readLatestVersion() {
100
+ const now = (this.deps.now ?? Date.now)();
101
+ if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) {
102
+ return this.latestVersionCache.version;
103
+ }
104
+ try {
105
+ const version = stripTagPrefix(await this.deps.fetchLatestVersion());
106
+ if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
107
+ throw new Error("OpenCode registry returned an invalid version");
108
+ }
109
+ this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS };
110
+ return version;
111
+ }
112
+ catch (error) {
113
+ throw new OpenCodeUpdateError("update_check_failed", error instanceof Error ? error.message : "Unable to check the latest OpenCode version");
114
+ }
115
+ }
116
+ }
117
+ export async function fetchLatestOpenCodeVersion() {
118
+ const response = await fetch(OPENCODE_LATEST_URL, {
119
+ headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" },
120
+ signal: AbortSignal.timeout(10_000),
121
+ });
122
+ if (!response.ok) {
123
+ throw new Error(`OpenCode registry responded with ${response.status}`);
124
+ }
125
+ const payload = (await response.json());
126
+ if (typeof payload.version !== "string") {
127
+ throw new Error("OpenCode registry response did not include a version");
128
+ }
129
+ return payload.version;
130
+ }
131
+ export function createOpenCodeUpdateService(settings, workspaceManager) {
132
+ const binaryResolver = new BinaryResolver(settings);
133
+ return new OpenCodeUpdateService({
134
+ resolveBinary: () => {
135
+ const binary = binaryResolver.resolveDefault();
136
+ return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) };
137
+ },
138
+ probeBinary: probeBinaryVersion,
139
+ findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath),
140
+ fetchLatestVersion: fetchLatestOpenCodeVersion,
141
+ upgradeInstance: async (instanceId, target) => {
142
+ const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS });
143
+ if (!client) {
144
+ throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready");
145
+ }
146
+ const { data } = await client.global.upgrade({ target }, { throwOnError: true });
147
+ return data;
148
+ },
149
+ });
150
+ }
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { OpenCodeUpdateError, OpenCodeUpdateService } from "./service";
4
+ function createDeps(overrides = {}) {
5
+ let currentVersion = "1.0.0";
6
+ return {
7
+ resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
8
+ probeBinary: () => ({ valid: true, version: currentVersion }),
9
+ findReadyInstanceId: () => "workspace-1",
10
+ fetchLatestVersion: async () => "1.1.0",
11
+ upgradeInstance: async (_instanceId, target) => {
12
+ currentVersion = target;
13
+ return { success: true, version: target };
14
+ },
15
+ ...overrides,
16
+ };
17
+ }
18
+ test("reports an available update and caches the latest version", async () => {
19
+ let checks = 0;
20
+ const service = new OpenCodeUpdateService(createDeps({
21
+ fetchLatestVersion: async () => {
22
+ checks += 1;
23
+ return "1.1.0";
24
+ },
25
+ }));
26
+ assert.deepEqual(await service.getStatus(), {
27
+ currentVersion: "1.0.0",
28
+ latestVersion: "1.1.0",
29
+ updateAvailable: true,
30
+ canUpgrade: true,
31
+ });
32
+ await service.getStatus();
33
+ assert.equal(checks, 1);
34
+ });
35
+ test("keeps the update visible when no matching instance is ready", async () => {
36
+ const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }));
37
+ assert.deepEqual(await service.getStatus(), {
38
+ currentVersion: "1.0.0",
39
+ latestVersion: "1.1.0",
40
+ updateAvailable: true,
41
+ canUpgrade: false,
42
+ });
43
+ });
44
+ test("preserves the installed version when the registry check fails", async () => {
45
+ const service = new OpenCodeUpdateService(createDeps({
46
+ fetchLatestVersion: async () => {
47
+ throw new Error("registry unavailable");
48
+ },
49
+ }));
50
+ assert.deepEqual(await service.getStatus(), {
51
+ currentVersion: "1.0.0",
52
+ latestVersion: null,
53
+ updateAvailable: null,
54
+ canUpgrade: false,
55
+ checkError: "update_check_failed",
56
+ });
57
+ });
58
+ test("upgrades through the matching OpenCode instance to the advertised version", async () => {
59
+ const calls = [];
60
+ let currentVersion = "1.0.0";
61
+ const service = new OpenCodeUpdateService(createDeps({
62
+ probeBinary: () => ({ valid: true, version: currentVersion }),
63
+ upgradeInstance: async (instanceId, target) => {
64
+ calls.push({ instanceId, target });
65
+ currentVersion = target;
66
+ return { success: true, version: target };
67
+ },
68
+ }));
69
+ assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" });
70
+ assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }]);
71
+ });
72
+ test("rejects success when the configured binary was not updated", async () => {
73
+ const service = new OpenCodeUpdateService(createDeps({
74
+ probeBinary: () => ({ valid: true, version: "1.0.0" }),
75
+ upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
76
+ }));
77
+ await assert.rejects(() => service.upgrade(), (error) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed");
78
+ });
79
+ test("joins concurrent upgrades for the same binary", async () => {
80
+ let currentVersion = "1.0.0";
81
+ let upgrades = 0;
82
+ let finishUpgrade;
83
+ const gate = new Promise((resolve) => {
84
+ finishUpgrade = resolve;
85
+ });
86
+ const service = new OpenCodeUpdateService(createDeps({
87
+ probeBinary: () => ({ valid: true, version: currentVersion }),
88
+ upgradeInstance: async (_instanceId, target) => {
89
+ upgrades += 1;
90
+ await gate;
91
+ currentVersion = target;
92
+ return { success: true, version: target };
93
+ },
94
+ }));
95
+ const first = service.upgrade();
96
+ const second = service.upgrade();
97
+ finishUpgrade?.();
98
+ assert.deepEqual(await Promise.all([first, second]), [
99
+ { success: true, version: "1.1.0" },
100
+ { success: true, version: "1.1.0" },
101
+ ]);
102
+ assert.equal(upgrades, 1);
103
+ });
104
+ test("rejects an upgrade when no matching OpenCode instance is running", async () => {
105
+ const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }));
106
+ await assert.rejects(() => service.upgrade(), (error) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance");
107
+ });
@@ -8,27 +8,20 @@ import { Agent, fetch } from "undici";
8
8
  import { RemoteProxySessionManager } from "../remote-proxy";
9
9
  import { resolveHttpsOptions } from "../tls";
10
10
  const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"));
11
- const sharedTls = resolveHttpsOptions({
12
- enabled: true,
13
- configDir: sharedTempDir,
14
- host: "127.0.0.1",
15
- logger: createStubLogger(),
16
- });
17
- if (!sharedTls) {
11
+ const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() });
12
+ if (!sharedTls)
18
13
  throw new Error("Failed to generate HTTPS options for remote proxy tests");
19
- }
20
14
  const sharedHttpsOptions = sharedTls.httpsOptions;
21
15
  const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
22
16
  const managers = new Set();
23
17
  afterEach(async () => {
24
- for (const manager of managers) {
25
- await disposeManager(manager);
26
- }
18
+ for (const manager of managers)
19
+ await manager.shutdown().catch(() => undefined);
27
20
  managers.clear();
28
21
  });
29
- after(() => {
22
+ after(async () => {
30
23
  fs.rmSync(sharedTempDir, { recursive: true, force: true });
31
- httpsDispatcher.close().catch(() => { });
24
+ await httpsDispatcher.destroy().catch(() => { });
32
25
  });
33
26
  describe("RemoteProxySessionManager", () => {
34
27
  it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
@@ -66,8 +59,7 @@ describe("RemoteProxySessionManager", () => {
66
59
  const requestUrl = req.url ?? "";
67
60
  if (requestUrl === "/base/redirect") {
68
61
  res.writeHead(302, { location: "/base/after?ok=1" });
69
- res.end();
70
- return;
62
+ return res.end();
71
63
  }
72
64
  res.writeHead(200, { "content-type": "text/plain" });
73
65
  res.end(requestUrl);
@@ -95,13 +87,11 @@ describe("RemoteProxySessionManager", () => {
95
87
  "content-type": "text/plain",
96
88
  "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
97
89
  });
98
- res.end("ok");
99
- return;
90
+ return res.end("ok");
100
91
  }
101
92
  if (requestUrl === "/base/whoami") {
102
93
  res.writeHead(200, { "content-type": "text/plain" });
103
- res.end(req.headers.cookie ?? "");
104
- return;
94
+ return res.end(req.headers.cookie ?? "");
105
95
  }
106
96
  res.writeHead(404, { "content-type": "text/plain" });
107
97
  res.end(requestUrl);
@@ -116,7 +106,7 @@ describe("RemoteProxySessionManager", () => {
116
106
  const session3 = await createSession(manager, `${upstreamBaseUrl}/base`);
117
107
  const internalSessions = manager.sessions;
118
108
  const internalCleanup = manager.cleanupExpiredSessions;
119
- internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 * 60000;
109
+ internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 * 60_000;
120
110
  await internalCleanup.call(manager);
121
111
  assert.equal(internalSessions.has(session3.sessionId), false);
122
112
  assert.equal(await manager.deleteSession(session3.sessionId), false);
@@ -139,7 +129,7 @@ describe("RemoteProxySessionManager", () => {
139
129
  });
140
130
  it("waits for in-flight idle cleanup during shutdown", async () => {
141
131
  await withUpstreamServer(async (upstreamBaseUrl) => {
142
- const manager = createSessionManager({ disposalTimeoutMs: 1000 });
132
+ const manager = createSessionManager({ disposalTimeoutMs: 1_000 });
143
133
  const session = await createSession(manager, `${upstreamBaseUrl}/base`);
144
134
  const internalSession = manager.sessions.get(session.sessionId);
145
135
  const closeGate = deferred();
@@ -148,9 +138,8 @@ describe("RemoteProxySessionManager", () => {
148
138
  await closeGate.promise;
149
139
  return originalClose();
150
140
  };
151
- internalSession.lastAccessAt = Date.now() - 31 * 60000;
141
+ internalSession.lastAccessAt = Date.now() - 31 * 60_000;
152
142
  const cleanup = manager.cleanupExpiredSessions();
153
- assert.equal(manager.pendingDisposals.size, 1);
154
143
  let shutdownSettled = false;
155
144
  const shutdown = manager.shutdown().then(() => {
156
145
  shutdownSettled = true;
@@ -160,7 +149,7 @@ describe("RemoteProxySessionManager", () => {
160
149
  closeGate.resolve();
161
150
  await cleanup;
162
151
  await shutdown;
163
- assert.equal(manager.pendingDisposals.size, 0);
152
+ assert.equal(manager.disposals.size, 0);
164
153
  }, (_req, res) => {
165
154
  res.writeHead(200).end("ok");
166
155
  });
@@ -177,12 +166,11 @@ describe("RemoteProxySessionManager", () => {
177
166
  new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
178
167
  ]);
179
168
  assert.equal(manager.sessions.size, 0);
180
- assert.equal(manager.pendingDisposals.size, 0);
169
+ assert.equal(manager.disposals.size, 0);
181
170
  }, (req, res) => {
182
171
  if (req.url === "/base/events") {
183
172
  res.writeHead(200, { "content-type": "text/event-stream" });
184
- res.write("data: connected\n\n");
185
- return;
173
+ return void res.write("data: connected\n\n");
186
174
  }
187
175
  res.writeHead(200).end("ok");
188
176
  });
@@ -193,12 +181,18 @@ describe("RemoteProxySessionManager", () => {
193
181
  const session = await createSession(manager, `${upstreamBaseUrl}/base`);
194
182
  const internalSession = manager.sessions.get(session.sessionId);
195
183
  const originalClose = internalSession.app.close.bind(internalSession.app);
184
+ let failClose = true;
196
185
  internalSession.app.close = async () => {
197
186
  await originalClose();
198
- throw new Error("close failed");
187
+ if (failClose) {
188
+ failClose = false;
189
+ throw new Error("close failed");
190
+ }
199
191
  };
200
192
  await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/);
201
- await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/);
193
+ // A completed deletion failure predating shutdown must not poison it.
194
+ await manager.shutdown();
195
+ assert.equal(manager.sessions.size, 0);
202
196
  }, (_req, res) => {
203
197
  res.writeHead(200).end("ok");
204
198
  });
@@ -210,31 +204,58 @@ describe("RemoteProxySessionManager", () => {
210
204
  const shutdown = manager.shutdown();
211
205
  await assert.rejects(creation, /shutting down/);
212
206
  await shutdown;
213
- assert.equal(manager.pendingCreations.size, 0);
207
+ assert.equal(manager.creations.size, 0);
214
208
  assert.equal(manager.sessions.size, 0);
215
209
  await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/);
216
210
  }, (_req, res) => {
217
211
  res.writeHead(200).end("ok");
218
212
  });
219
213
  });
214
+ it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => {
215
+ await withUpstreamServer(async (upstreamBaseUrl) => {
216
+ const manager = createSessionManager();
217
+ const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false);
218
+ const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true);
219
+ const sessions = manager.sessions;
220
+ assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent);
221
+ assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent);
222
+ assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher);
223
+ const closeGate = deferred();
224
+ const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app);
225
+ let failClose = true;
226
+ sessions.get(verified.sessionId).app.close = async () => {
227
+ await closeGate.promise;
228
+ await originalClose();
229
+ if (failClose) {
230
+ failClose = false;
231
+ throw new Error("current close failed");
232
+ }
233
+ };
234
+ const disposal = manager.deleteSession(verified.sessionId);
235
+ const first = manager.shutdown();
236
+ const concurrent = manager.shutdown();
237
+ assert.strictEqual(first, concurrent);
238
+ closeGate.resolve();
239
+ await assert.rejects(disposal, /Remote proxy disposal failed/);
240
+ await assert.rejects(first, (error) => error instanceof AggregateError && error.errors.some((cause) => cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested)))));
241
+ await manager.shutdown();
242
+ assert.equal(sessions.size, 0);
243
+ }, (_req, res) => {
244
+ res.writeHead(200).end("ok");
245
+ });
246
+ });
220
247
  });
221
248
  function createSessionManager(options = {}) {
222
249
  const manager = new RemoteProxySessionManager({
223
- authManager: {
224
- isLoopbackRequest: () => true,
225
- },
226
- logger: createStubLogger(),
227
- httpsOptions: sharedHttpsOptions,
228
- ...options,
250
+ authManager: { isLoopbackRequest: () => true },
251
+ logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options,
229
252
  });
230
253
  managers.add(manager);
231
254
  return manager;
232
255
  }
233
256
  function deferred() {
234
257
  let resolve;
235
- const promise = new Promise((resolvePromise) => {
236
- resolve = resolvePromise;
237
- });
258
+ const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
238
259
  return { promise, resolve };
239
260
  }
240
261
  async function createSession(manager, baseUrl) {
@@ -253,34 +274,28 @@ async function activateSession(session) {
253
274
  headers: { "content-type": "application/json" },
254
275
  body: JSON.stringify({ token: session.token }),
255
276
  });
256
- if (!response.ok) {
277
+ if (!response.ok)
257
278
  return false;
258
- }
259
279
  const body = (await response.json());
260
280
  return body.ok === true;
261
281
  }
262
282
  function getSetCookie(response) {
263
283
  const values = response.headers.getSetCookie?.();
264
- if (Array.isArray(values) && values.length > 0) {
284
+ if (Array.isArray(values) && values.length > 0)
265
285
  return values;
266
- }
267
286
  const fallback = response.headers.get("set-cookie");
268
287
  return fallback ? [fallback] : [];
269
288
  }
270
289
  async function proxyFetch(url, init) {
271
290
  return fetch(url, { dispatcher: httpsDispatcher, ...init });
272
291
  }
273
- async function disposeManager(manager) {
274
- await manager.shutdown().catch(() => undefined);
275
- }
276
292
  async function withUpstreamServer(callback, handler) {
277
293
  const server = http.createServer(handler);
278
294
  await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
279
295
  try {
280
296
  const address = server.address();
281
- if (!address || typeof address === "string") {
297
+ if (!address || typeof address === "string")
282
298
  throw new Error("Failed to resolve upstream server address");
283
- }
284
299
  await callback(`http://127.0.0.1:${address.port}`);
285
300
  }
286
301
  finally {
@@ -288,13 +303,6 @@ async function withUpstreamServer(callback, handler) {
288
303
  }
289
304
  }
290
305
  function createStubLogger() {
291
- const logger = {
292
- info() { },
293
- warn() { },
294
- error() { },
295
- child() {
296
- return logger;
297
- },
298
- };
306
+ const logger = { info() { }, warn() { }, error() { }, child() { return logger; } };
299
307
  return logger;
300
308
  }