@neuralnomads/codenomad-dev 0.18.0-dev-20260715-07e8ce68 → 0.18.0-dev-20260716-40cee071
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/README.md +12 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/dist/events/bus.js +13 -0
- package/dist/events/bus.test.js +37 -0
- package/dist/index.js +33 -56
- package/dist/index.test.js +32 -0
- package/dist/server/__tests__/remote-proxy.test.js +102 -6
- package/dist/server/http-server.js +2 -0
- package/dist/server/remote-proxy.js +96 -9
- package/dist/server/routes/usage.js +18 -0
- package/dist/server/routes/usage.test.js +26 -0
- package/dist/server/routes/workspaces.js +32 -1
- package/dist/server/routes/workspaces.test.js +75 -0
- package/dist/settings/binaries.js +4 -1
- package/dist/settings/binaries.test.js +24 -0
- package/dist/shutdown.js +75 -0
- package/dist/shutdown.test.js +111 -0
- package/dist/usage/providers/api-key.js +171 -0
- package/dist/usage/providers/google.js +171 -0
- package/dist/usage/providers/minimax.js +109 -0
- package/dist/usage/providers/oauth.js +111 -0
- package/dist/usage/providers/special.js +148 -0
- package/dist/usage/service.js +83 -0
- package/dist/usage/service.test.js +59 -0
- package/dist/usage/shared.js +130 -0
- package/dist/usage/shared.test.js +33 -0
- package/dist/usage/types.js +1 -0
- package/dist/workspaces/__tests__/spawn.test.js +64 -7
- package/dist/workspaces/__tests__/workspace-identity.test.js +177 -25
- package/dist/workspaces/launch-cleanup.js +132 -0
- package/dist/workspaces/launch-cleanup.test.js +33 -0
- package/dist/workspaces/manager.js +393 -135
- package/dist/workspaces/manager.test.js +318 -0
- package/dist/workspaces/process-identity.js +408 -0
- package/dist/workspaces/process-identity.test.js +165 -0
- package/dist/workspaces/runtime.js +567 -122
- package/dist/workspaces/runtime.test.js +603 -0
- package/dist/workspaces/spawn.js +86 -15
- package/package.json +1 -1
- package/public/assets/{FilesTab-EPvldvn1.js → FilesTab-BjztpSdj.js} +2 -2
- package/public/assets/{GitChangesTab-ofjovmv2.js → GitChangesTab-B9XraSoR.js} +2 -2
- package/public/assets/{SplitFilePanel-CyoPkcLd.js → SplitFilePanel-Cu_5-Z-E.js} +1 -1
- package/public/assets/StatusTab-D_IE79r-.js +1 -0
- package/public/assets/{align-justify-Bcf1-ZOl.js → align-justify-BKS9gyRk.js} +1 -1
- package/public/assets/{bundle-full-D5dzvToA.js → bundle-full-CXGXXlzR.js} +1 -1
- package/public/assets/{diff-viewer-DViXCI-4.js → diff-viewer-CSNOYoEB.js} +1 -1
- package/public/assets/{git-diff-vendor-CSgooKT_.js → git-diff-vendor-CYS3ApzA.js} +1 -1
- package/public/assets/index-B3YjNCh4.js +1 -0
- package/public/assets/index-B3ZD2_SU.js +1 -0
- package/public/assets/index-Bh2ofaQo.js +1 -0
- package/public/assets/index-CM0c9xRZ.js +1 -0
- package/public/assets/index-CdDxouJl.js +2 -0
- package/public/assets/index-D06Y2cFq.js +1 -0
- package/public/assets/{index-lf4nUOU1.js → index-DVc4ls8c.js} +1 -1
- package/public/assets/index-PmE51vPh.js +1 -0
- package/public/assets/{index-BMvFMsuy.css → index-Qj63IiSj.css} +1 -1
- package/public/assets/index-VY_zwmsG.js +1 -0
- package/public/assets/index-qiiH7egV.js +1 -0
- package/public/assets/{index-UbL8tz_V.js → index-sLe9hEfQ.js} +1 -1
- package/public/assets/{loading-CZ_tt3h0.js → loading-CDmt9OJU.js} +1 -1
- package/public/assets/main-CTN6Wwz1.js +68 -0
- package/public/assets/{markdown-G11aN6hz.js → markdown-1ZLSxCFT.js} +55 -44
- package/public/assets/monaco-viewer-D6JgH76W.js +26 -0
- package/public/assets/{tool-call-BsSXWErm.js → tool-call-CyXb63LH.js} +32 -32
- package/public/assets/{unified-picker-BRNI3UvE.js → unified-picker-DCCdSVwI.js} +1 -1
- package/public/assets/{wrap-text-B7up-uv7.js → wrap-text-pNMdTIq3.js} +1 -1
- package/public/index.html +5 -5
- package/public/loading.html +5 -5
- package/public/sw.js +1 -1
- package/public/assets/StatusTab-Bgu1Fh24.js +0 -1
- package/public/assets/index-3KsjbWGP.js +0 -1
- package/public/assets/index-B0WK8-cF.js +0 -1
- package/public/assets/index-BDv_iVtj.js +0 -1
- package/public/assets/index-BjxZvyb9.js +0 -2
- package/public/assets/index-BleKf9Nj.js +0 -1
- package/public/assets/index-CiIQ43NW.js +0 -1
- package/public/assets/index-DBquqL6Q.js +0 -1
- package/public/assets/index-yf9zolv4.js +0 -1
- package/public/assets/index-ypaL7i_s.js +0 -1
- package/public/assets/main-CAkg_fp_.js +0 -68
- package/public/assets/monaco-viewer-CZefBfct.js +0 -26
package/README.md
CHANGED
|
@@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|
|
217
217
|
|
|
218
218
|
- **Config**: `~/.config/codenomad/config.json`
|
|
219
219
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
|
220
|
+
|
|
221
|
+
### Provider Plan Usage
|
|
222
|
+
|
|
223
|
+
The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API.
|
|
224
|
+
|
|
225
|
+
Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
|
|
226
|
+
|
|
227
|
+
- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
|
|
228
|
+
- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
|
|
229
|
+
- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
|
|
230
|
+
- Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
|
|
231
|
+
- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
The provider usage adapters are derived from OpenChamber:
|
|
4
|
+
https://github.com/openchamber/openchamber
|
|
5
|
+
|
|
6
|
+
MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Bohdan Triapitsyn
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/dist/events/bus.js
CHANGED
|
@@ -3,8 +3,19 @@ export class EventBus extends EventEmitter {
|
|
|
3
3
|
constructor(logger) {
|
|
4
4
|
super();
|
|
5
5
|
this.logger = logger;
|
|
6
|
+
this.instanceStatuses = new Map();
|
|
6
7
|
}
|
|
7
8
|
publish(event) {
|
|
9
|
+
if (event.type === "instance.eventStatus") {
|
|
10
|
+
const terminal = event.status === "disconnected"
|
|
11
|
+
&& (event.reason === "workspace stopped" || event.reason === "workspace error");
|
|
12
|
+
if (terminal) {
|
|
13
|
+
this.instanceStatuses.delete(event.instanceId);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
this.instanceStatuses.set(event.instanceId, event);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
|
9
20
|
this.logger?.debug({ type: event.type }, "Publishing workspace event");
|
|
10
21
|
if (this.logger?.isLevelEnabled("trace")) {
|
|
@@ -29,6 +40,8 @@ export class EventBus extends EventEmitter {
|
|
|
29
40
|
this.on("instance.eventStatus", handler);
|
|
30
41
|
this.on("yolo.stateChanged", handler);
|
|
31
42
|
this.on("yolo.autoAccepted", handler);
|
|
43
|
+
for (const status of this.instanceStatuses.values())
|
|
44
|
+
listener(status);
|
|
32
45
|
return () => {
|
|
33
46
|
this.off("workspace.created", handler);
|
|
34
47
|
this.off("workspace.started", handler);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { EventBus } from "./bus";
|
|
4
|
+
describe("event bus instance status replay", () => {
|
|
5
|
+
it("replays the latest instance status to a late subscriber", () => {
|
|
6
|
+
const bus = new EventBus();
|
|
7
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" });
|
|
8
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
|
|
9
|
+
const received = [];
|
|
10
|
+
bus.onEvent((event) => received.push(event));
|
|
11
|
+
assert.deepEqual(received, [
|
|
12
|
+
{ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
it("delivers terminal disconnects live without replaying stopped workspaces", () => {
|
|
16
|
+
const bus = new EventBus();
|
|
17
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
|
|
18
|
+
const live = [];
|
|
19
|
+
bus.onEvent((event) => live.push(event));
|
|
20
|
+
live.length = 0;
|
|
21
|
+
bus.publish({
|
|
22
|
+
type: "instance.eventStatus",
|
|
23
|
+
instanceId: "workspace-1",
|
|
24
|
+
status: "disconnected",
|
|
25
|
+
reason: "workspace stopped",
|
|
26
|
+
});
|
|
27
|
+
assert.deepEqual(live, [{
|
|
28
|
+
type: "instance.eventStatus",
|
|
29
|
+
instanceId: "workspace-1",
|
|
30
|
+
status: "disconnected",
|
|
31
|
+
reason: "workspace stopped",
|
|
32
|
+
}]);
|
|
33
|
+
const replayed = [];
|
|
34
|
+
bus.onEvent((event) => replayed.push(event));
|
|
35
|
+
assert.deepEqual(replayed, []);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { ClientConnectionManager } from "./clients/connection-manager";
|
|
|
31
31
|
import { PluginChannelManager } from "./plugins/channel";
|
|
32
32
|
import { VoiceModeManager } from "./plugins/voice-mode";
|
|
33
33
|
import { runCliUpgrade } from "./cli-upgrade";
|
|
34
|
+
import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown";
|
|
34
35
|
const require = createRequire(import.meta.url);
|
|
35
36
|
const packageJson = require("../package.json");
|
|
36
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -40,6 +41,10 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
40
41
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
|
|
41
42
|
const DEFAULT_HTTPS_PORT = 9898;
|
|
42
43
|
const DEFAULT_HTTP_PORT = 9899;
|
|
44
|
+
export function installShutdownSignalHandlers(source, shutdown) {
|
|
45
|
+
source.on("SIGINT", () => void shutdown("SIGINT"));
|
|
46
|
+
source.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
47
|
+
}
|
|
43
48
|
function parseCliOptions(argv) {
|
|
44
49
|
const program = new Command()
|
|
45
50
|
.name("codenomad")
|
|
@@ -424,61 +429,33 @@ async function main() {
|
|
|
424
429
|
if (options.launch) {
|
|
425
430
|
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }));
|
|
426
431
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
await sidecarManager.shutdown();
|
|
444
|
-
}
|
|
445
|
-
catch (error) {
|
|
446
|
-
logger.error({ err: error }, "SideCar manager shutdown failed");
|
|
447
|
-
}
|
|
448
|
-
try {
|
|
449
|
-
clientConnectionManager.shutdown();
|
|
450
|
-
}
|
|
451
|
-
catch (error) {
|
|
452
|
-
logger.warn({ err: error }, "Client connection manager shutdown failed");
|
|
453
|
-
}
|
|
454
|
-
try {
|
|
455
|
-
await workspaceManager.shutdown();
|
|
456
|
-
logger.info("Workspace manager shutdown complete");
|
|
457
|
-
}
|
|
458
|
-
catch (error) {
|
|
459
|
-
logger.error({ err: error }, "Workspace manager shutdown failed");
|
|
460
|
-
}
|
|
461
|
-
})();
|
|
462
|
-
const shutdownHttp = (async () => {
|
|
463
|
-
try {
|
|
464
|
-
await Promise.allSettled(servers.map((srv) => srv.stop()));
|
|
432
|
+
const shutdown = createServerShutdownHandler({
|
|
433
|
+
logger,
|
|
434
|
+
shutdown: () => orchestrateServerShutdown({
|
|
435
|
+
stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
|
|
436
|
+
stopSidecars: () => sidecarManager.shutdown(),
|
|
437
|
+
stopClientConnections: () => clientConnectionManager.shutdown(),
|
|
438
|
+
stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
|
|
439
|
+
stopWorkspaces: () => workspaceManager.shutdown(),
|
|
440
|
+
stopHttpServers: async () => {
|
|
441
|
+
const results = await Promise.allSettled(servers.map((srv) => srv.stop()));
|
|
442
|
+
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
|
443
|
+
if (failures.length > 0) {
|
|
444
|
+
const error = new Error("One or more HTTP servers failed to stop");
|
|
445
|
+
error.failures = failures;
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
465
448
|
logger.info("HTTP server(s) stopped");
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
logger
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
process.on("SIGTERM", shutdown);
|
|
449
|
+
},
|
|
450
|
+
stopReleaseMonitor: () => devReleaseMonitor?.stop(),
|
|
451
|
+
}, logger),
|
|
452
|
+
});
|
|
453
|
+
installShutdownSignalHandlers(process, shutdown);
|
|
454
|
+
}
|
|
455
|
+
if (path.resolve(process.argv[1] ?? "") === __filename) {
|
|
456
|
+
main().catch((error) => {
|
|
457
|
+
const logger = createLogger({ component: "app" });
|
|
458
|
+
logger.error({ err: error }, "CLI server crashed");
|
|
459
|
+
process.exit(1);
|
|
460
|
+
});
|
|
479
461
|
}
|
|
480
|
-
main().catch((error) => {
|
|
481
|
-
const logger = createLogger({ component: "app" });
|
|
482
|
-
logger.error({ err: error }, "CLI server crashed");
|
|
483
|
-
process.exit(1);
|
|
484
|
-
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { installShutdownSignalHandlers } from "./index";
|
|
4
|
+
import { createServerShutdownHandler } from "./shutdown";
|
|
5
|
+
describe("CLI shutdown signal registration", () => {
|
|
6
|
+
it("routes a second process signal to forced nonzero escalation", async () => {
|
|
7
|
+
const listeners = new Map();
|
|
8
|
+
let finishCleanup;
|
|
9
|
+
const cleanup = new Promise((resolve) => {
|
|
10
|
+
finishCleanup = resolve;
|
|
11
|
+
});
|
|
12
|
+
const exits = [];
|
|
13
|
+
const shutdown = createServerShutdownHandler({
|
|
14
|
+
shutdown: () => cleanup,
|
|
15
|
+
logger: {
|
|
16
|
+
info: () => undefined,
|
|
17
|
+
warn: () => undefined,
|
|
18
|
+
error: () => undefined,
|
|
19
|
+
},
|
|
20
|
+
forceExit: (code) => exits.push(code),
|
|
21
|
+
setExitCode: () => undefined,
|
|
22
|
+
});
|
|
23
|
+
installShutdownSignalHandlers({
|
|
24
|
+
on: (signal, listener) => listeners.set(signal, listener),
|
|
25
|
+
}, shutdown);
|
|
26
|
+
listeners.get("SIGINT")?.();
|
|
27
|
+
listeners.get("SIGTERM")?.();
|
|
28
|
+
assert.deepEqual(exits, [1]);
|
|
29
|
+
finishCleanup();
|
|
30
|
+
await cleanup;
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -125,18 +125,118 @@ describe("RemoteProxySessionManager", () => {
|
|
|
125
125
|
res.end("ok");
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
it("closes every session listener during shutdown", async () => {
|
|
129
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
130
|
+
const manager = createSessionManager();
|
|
131
|
+
const first = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
132
|
+
await createSession(manager, `${upstreamBaseUrl}/other`);
|
|
133
|
+
await manager.shutdown();
|
|
134
|
+
assert.equal(manager.sessions.size, 0);
|
|
135
|
+
await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`));
|
|
136
|
+
}, (_req, res) => {
|
|
137
|
+
res.writeHead(200).end("ok");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
it("waits for in-flight idle cleanup during shutdown", async () => {
|
|
141
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
142
|
+
const manager = createSessionManager({ disposalTimeoutMs: 1000 });
|
|
143
|
+
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
144
|
+
const internalSession = manager.sessions.get(session.sessionId);
|
|
145
|
+
const closeGate = deferred();
|
|
146
|
+
const originalClose = internalSession.app.close.bind(internalSession.app);
|
|
147
|
+
internalSession.app.close = async () => {
|
|
148
|
+
await closeGate.promise;
|
|
149
|
+
return originalClose();
|
|
150
|
+
};
|
|
151
|
+
internalSession.lastAccessAt = Date.now() - 31 * 60000;
|
|
152
|
+
const cleanup = manager.cleanupExpiredSessions();
|
|
153
|
+
assert.equal(manager.pendingDisposals.size, 1);
|
|
154
|
+
let shutdownSettled = false;
|
|
155
|
+
const shutdown = manager.shutdown().then(() => {
|
|
156
|
+
shutdownSettled = true;
|
|
157
|
+
});
|
|
158
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
159
|
+
assert.equal(shutdownSettled, false);
|
|
160
|
+
closeGate.resolve();
|
|
161
|
+
await cleanup;
|
|
162
|
+
await shutdown;
|
|
163
|
+
assert.equal(manager.pendingDisposals.size, 0);
|
|
164
|
+
}, (_req, res) => {
|
|
165
|
+
res.writeHead(200).end("ok");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it("aborts a stalled event stream during bounded shutdown", async () => {
|
|
169
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
170
|
+
const manager = createSessionManager({ disposalTimeoutMs: 100 });
|
|
171
|
+
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
172
|
+
await activateSession(session);
|
|
173
|
+
const response = await proxyFetch(`${session.proxyOrigin}/events`);
|
|
174
|
+
assert.equal(response.status, 200);
|
|
175
|
+
await Promise.race([
|
|
176
|
+
manager.shutdown(),
|
|
177
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
|
|
178
|
+
]);
|
|
179
|
+
assert.equal(manager.sessions.size, 0);
|
|
180
|
+
assert.equal(manager.pendingDisposals.size, 0);
|
|
181
|
+
}, (req, res) => {
|
|
182
|
+
if (req.url === "/base/events") {
|
|
183
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
184
|
+
res.write("data: connected\n\n");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
res.writeHead(200).end("ok");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
it("surfaces listener disposal failures", async () => {
|
|
191
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
192
|
+
const manager = createSessionManager();
|
|
193
|
+
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
194
|
+
const internalSession = manager.sessions.get(session.sessionId);
|
|
195
|
+
const originalClose = internalSession.app.close.bind(internalSession.app);
|
|
196
|
+
internalSession.app.close = async () => {
|
|
197
|
+
await originalClose();
|
|
198
|
+
throw new Error("close failed");
|
|
199
|
+
};
|
|
200
|
+
await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/);
|
|
201
|
+
await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/);
|
|
202
|
+
}, (_req, res) => {
|
|
203
|
+
res.writeHead(200).end("ok");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it("waits for in-flight creation and rejects sessions that cross shutdown", async () => {
|
|
207
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
208
|
+
const manager = createSessionManager();
|
|
209
|
+
const creation = manager.createSession(`${upstreamBaseUrl}/base`, false);
|
|
210
|
+
const shutdown = manager.shutdown();
|
|
211
|
+
await assert.rejects(creation, /shutting down/);
|
|
212
|
+
await shutdown;
|
|
213
|
+
assert.equal(manager.pendingCreations.size, 0);
|
|
214
|
+
assert.equal(manager.sessions.size, 0);
|
|
215
|
+
await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/);
|
|
216
|
+
}, (_req, res) => {
|
|
217
|
+
res.writeHead(200).end("ok");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
128
220
|
});
|
|
129
|
-
function createSessionManager() {
|
|
221
|
+
function createSessionManager(options = {}) {
|
|
130
222
|
const manager = new RemoteProxySessionManager({
|
|
131
223
|
authManager: {
|
|
132
224
|
isLoopbackRequest: () => true,
|
|
133
225
|
},
|
|
134
226
|
logger: createStubLogger(),
|
|
135
227
|
httpsOptions: sharedHttpsOptions,
|
|
228
|
+
...options,
|
|
136
229
|
});
|
|
137
230
|
managers.add(manager);
|
|
138
231
|
return manager;
|
|
139
232
|
}
|
|
233
|
+
function deferred() {
|
|
234
|
+
let resolve;
|
|
235
|
+
const promise = new Promise((resolvePromise) => {
|
|
236
|
+
resolve = resolvePromise;
|
|
237
|
+
});
|
|
238
|
+
return { promise, resolve };
|
|
239
|
+
}
|
|
140
240
|
async function createSession(manager, baseUrl) {
|
|
141
241
|
const created = await manager.createSession(baseUrl, false);
|
|
142
242
|
const windowUrl = new URL(created.windowUrl);
|
|
@@ -171,11 +271,7 @@ async function proxyFetch(url, init) {
|
|
|
171
271
|
return fetch(url, { dispatcher: httpsDispatcher, ...init });
|
|
172
272
|
}
|
|
173
273
|
async function disposeManager(manager) {
|
|
174
|
-
|
|
175
|
-
for (const sessionId of sessions) {
|
|
176
|
-
await manager.deleteSession(sessionId);
|
|
177
|
-
}
|
|
178
|
-
clearInterval(manager.cleanupTimer);
|
|
274
|
+
await manager.shutdown().catch(() => undefined);
|
|
179
275
|
}
|
|
180
276
|
async function withUpstreamServer(callback, handler) {
|
|
181
277
|
const server = http.createServer(handler);
|
|
@@ -23,6 +23,7 @@ import { registerRemoteServerRoutes } from "./routes/remote-servers";
|
|
|
23
23
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy";
|
|
24
24
|
import { registerSideCarRoutes } from "./routes/sidecars";
|
|
25
25
|
import { registerPreviewRoutes } from "./routes/previews";
|
|
26
|
+
import { registerUsageRoutes } from "./routes/usage";
|
|
26
27
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
27
28
|
import { AutoAcceptManager } from "../permissions/auto-accept-manager";
|
|
28
29
|
import { createOpencodePermissionReplier } from "../permissions/opencode-replier";
|
|
@@ -224,6 +225,7 @@ export function createHttpServer(deps) {
|
|
|
224
225
|
registerSpeechRoutes(app, { speechService: deps.speechService });
|
|
225
226
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
|
|
226
227
|
registerPreviewRoutes(app, { previewManager: deps.previewManager });
|
|
228
|
+
registerUsageRoutes(app);
|
|
227
229
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });
|
|
228
230
|
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger });
|
|
229
231
|
setupSideCarWebSocketProxy(app, {
|
|
@@ -7,24 +7,46 @@ const LOOPBACK_HOST = "127.0.0.1";
|
|
|
7
7
|
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token";
|
|
8
8
|
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token";
|
|
9
9
|
const SESSION_IDLE_TTL_MS = 30 * 60000;
|
|
10
|
+
const SESSION_DISPOSAL_TIMEOUT_MS = 5000;
|
|
10
11
|
export class RemoteProxySessionManager {
|
|
11
12
|
constructor(options) {
|
|
12
13
|
this.options = options;
|
|
13
14
|
this.sessions = new Map();
|
|
15
|
+
this.pendingCreations = new Set();
|
|
16
|
+
this.pendingDisposals = new Set();
|
|
17
|
+
this.disposalFailures = [];
|
|
18
|
+
this.shuttingDown = false;
|
|
19
|
+
this.disposalTimeoutMs = Math.max(1, options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS);
|
|
14
20
|
this.cleanupTimer = setInterval(() => {
|
|
15
|
-
void this.cleanupExpiredSessions()
|
|
21
|
+
void this.cleanupExpiredSessions().catch((error) => {
|
|
22
|
+
this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session");
|
|
23
|
+
});
|
|
16
24
|
}, 60000);
|
|
17
25
|
this.cleanupTimer.unref();
|
|
18
26
|
}
|
|
19
27
|
async createSession(baseUrl, skipTlsVerify) {
|
|
28
|
+
if (this.shuttingDown) {
|
|
29
|
+
throw new Error("Remote proxy session manager is shutting down");
|
|
30
|
+
}
|
|
31
|
+
const creation = this.createSessionInternal(baseUrl, skipTlsVerify);
|
|
32
|
+
this.pendingCreations.add(creation);
|
|
33
|
+
try {
|
|
34
|
+
return await creation;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
this.pendingCreations.delete(creation);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async createSessionInternal(baseUrl, skipTlsVerify) {
|
|
20
41
|
if (!this.options.httpsOptions) {
|
|
21
42
|
throw new Error("Local HTTPS is required for remote proxy sessions");
|
|
22
43
|
}
|
|
23
44
|
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
|
24
45
|
const sessionId = randomUUID();
|
|
25
46
|
const bootstrapToken = randomBytes(32).toString("base64url");
|
|
26
|
-
const dispatcher = skipTlsVerify ?
|
|
27
|
-
const
|
|
47
|
+
const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {});
|
|
48
|
+
const abortController = new AbortController();
|
|
49
|
+
const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true });
|
|
28
50
|
let session = null;
|
|
29
51
|
app.removeAllContentTypeParsers();
|
|
30
52
|
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
|
|
@@ -82,6 +104,17 @@ export class RemoteProxySessionManager {
|
|
|
82
104
|
await proxyRequest({ request, reply, session, logger: this.options.logger });
|
|
83
105
|
});
|
|
84
106
|
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 });
|
|
107
|
+
if (this.shuttingDown) {
|
|
108
|
+
abortController.abort();
|
|
109
|
+
try {
|
|
110
|
+
await this.disposeResources(app, dispatcher);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
this.disposalFailures.push(error);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
throw new Error("Remote proxy session manager is shutting down");
|
|
117
|
+
}
|
|
85
118
|
const address = new URL(addressInfo);
|
|
86
119
|
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`);
|
|
87
120
|
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl);
|
|
@@ -98,6 +131,7 @@ export class RemoteProxySessionManager {
|
|
|
98
131
|
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
|
99
132
|
app,
|
|
100
133
|
dispatcher,
|
|
134
|
+
abortController,
|
|
101
135
|
createdAt: Date.now(),
|
|
102
136
|
lastAccessAt: Date.now(),
|
|
103
137
|
};
|
|
@@ -108,6 +142,27 @@ export class RemoteProxySessionManager {
|
|
|
108
142
|
async deleteSession(sessionId) {
|
|
109
143
|
return this.disposeSession(sessionId);
|
|
110
144
|
}
|
|
145
|
+
async shutdown() {
|
|
146
|
+
this.shuttingDown = true;
|
|
147
|
+
clearInterval(this.cleanupTimer);
|
|
148
|
+
const disposals = new Set(this.pendingDisposals);
|
|
149
|
+
await Promise.allSettled(Array.from(this.pendingCreations));
|
|
150
|
+
for (const sessionId of this.sessions.keys())
|
|
151
|
+
disposals.add(this.disposeSession(sessionId));
|
|
152
|
+
while (this.pendingDisposals.size > 0) {
|
|
153
|
+
const pending = Array.from(this.pendingDisposals);
|
|
154
|
+
for (const disposal of pending)
|
|
155
|
+
disposals.add(disposal);
|
|
156
|
+
await Promise.allSettled(pending);
|
|
157
|
+
}
|
|
158
|
+
const results = await Promise.allSettled(disposals);
|
|
159
|
+
const failures = [
|
|
160
|
+
...this.disposalFailures,
|
|
161
|
+
...results.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
|
162
|
+
];
|
|
163
|
+
if (failures.length > 0)
|
|
164
|
+
throw createDisposalError("Remote proxy shutdown failed", failures);
|
|
165
|
+
}
|
|
111
166
|
async cleanupExpiredSessions() {
|
|
112
167
|
const now = Date.now();
|
|
113
168
|
for (const session of Array.from(this.sessions.values())) {
|
|
@@ -117,17 +172,48 @@ export class RemoteProxySessionManager {
|
|
|
117
172
|
await this.disposeSession(session.id);
|
|
118
173
|
}
|
|
119
174
|
}
|
|
120
|
-
|
|
175
|
+
disposeSession(sessionId) {
|
|
121
176
|
const session = this.sessions.get(sessionId);
|
|
122
177
|
if (!session) {
|
|
123
|
-
return false;
|
|
178
|
+
return Promise.resolve(false);
|
|
124
179
|
}
|
|
125
180
|
this.sessions.delete(sessionId);
|
|
126
|
-
session.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
181
|
+
session.abortController.abort();
|
|
182
|
+
const disposal = this.disposeResources(session.app, session.dispatcher)
|
|
183
|
+
.then(() => {
|
|
184
|
+
this.options.logger.info({ sessionId }, "Disposed remote proxy session");
|
|
185
|
+
return true;
|
|
186
|
+
})
|
|
187
|
+
.catch((error) => {
|
|
188
|
+
this.disposalFailures.push(error);
|
|
189
|
+
throw error;
|
|
190
|
+
});
|
|
191
|
+
this.pendingDisposals.add(disposal);
|
|
192
|
+
void disposal.then(() => this.pendingDisposals.delete(disposal), () => this.pendingDisposals.delete(disposal));
|
|
193
|
+
return disposal;
|
|
130
194
|
}
|
|
195
|
+
async disposeResources(app, dispatcher) {
|
|
196
|
+
app.server.closeAllConnections?.();
|
|
197
|
+
const disposal = Promise.allSettled([app.close(), dispatcher.destroy()]);
|
|
198
|
+
let timeout;
|
|
199
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
200
|
+
timeout = setTimeout(() => reject(new Error(`Remote proxy disposal timed out after ${this.disposalTimeoutMs}ms`)), this.disposalTimeoutMs);
|
|
201
|
+
timeout.unref();
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const results = await Promise.race([disposal, deadline]);
|
|
205
|
+
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
206
|
+
if (failures.length > 0)
|
|
207
|
+
throw createDisposalError("Remote proxy disposal failed", failures);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (timeout)
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function createDisposalError(message, failures) {
|
|
216
|
+
return Object.assign(new Error(message), { failures });
|
|
131
217
|
}
|
|
132
218
|
function normalizeBaseUrl(input) {
|
|
133
219
|
const parsed = new URL(input.trim());
|
|
@@ -253,6 +339,7 @@ async function proxyRequest(args) {
|
|
|
253
339
|
method: request.method,
|
|
254
340
|
headers,
|
|
255
341
|
dispatcher: session.dispatcher,
|
|
342
|
+
signal: session.abortController.signal,
|
|
256
343
|
redirect: "manual",
|
|
257
344
|
};
|
|
258
345
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getProviderUsage } from "../../usage/service";
|
|
3
|
+
const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) });
|
|
4
|
+
const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() });
|
|
5
|
+
export function registerUsageRoutes(app) {
|
|
6
|
+
app.get("/api/usage/:providerId", async (request, reply) => {
|
|
7
|
+
try {
|
|
8
|
+
const params = UsageParamsSchema.parse(request.params);
|
|
9
|
+
const query = UsageQuerySchema.parse(request.query ?? {});
|
|
10
|
+
return await getProviderUsage(params.providerId, { modelId: query.modelId });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
request.log.error({ err: error }, "Failed to fetch provider usage");
|
|
14
|
+
reply.code(400);
|
|
15
|
+
return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" };
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { registerUsageRoutes } from "./usage";
|
|
5
|
+
test("returns a typed unsupported result without exposing server details", async () => {
|
|
6
|
+
const app = Fastify();
|
|
7
|
+
registerUsageRoutes(app);
|
|
8
|
+
try {
|
|
9
|
+
const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" });
|
|
10
|
+
assert.equal(response.statusCode, 200);
|
|
11
|
+
assert.deepEqual(response.json(), {
|
|
12
|
+
requestedProviderId: "unknown-provider",
|
|
13
|
+
providerId: null,
|
|
14
|
+
providerName: "unknown-provider",
|
|
15
|
+
modelId: "test-model",
|
|
16
|
+
supported: false,
|
|
17
|
+
configured: false,
|
|
18
|
+
ok: false,
|
|
19
|
+
windows: {},
|
|
20
|
+
fetchedAt: response.json().fetchedAt,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await app.close();
|
|
25
|
+
}
|
|
26
|
+
});
|