@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { hasContainerCodexAuth } from "./codex-container-auth.js";
|
|
6
|
+
|
|
7
|
+
describe("hasContainerCodexAuth", () => {
|
|
8
|
+
let tempHome: string;
|
|
9
|
+
let prevHome: string | undefined;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempHome = mkdtempSync(join(tmpdir(), "codex-auth-test-"));
|
|
13
|
+
prevHome = process.env.HOME;
|
|
14
|
+
process.env.HOME = tempHome;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (prevHome === undefined) {
|
|
19
|
+
delete process.env.HOME;
|
|
20
|
+
} else {
|
|
21
|
+
process.env.HOME = prevHome;
|
|
22
|
+
}
|
|
23
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns true when OPENAI_API_KEY env var is provided", () => {
|
|
27
|
+
expect(hasContainerCodexAuth({ OPENAI_API_KEY: "sk-test" })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true when CODEX_API_KEY env var is provided", () => {
|
|
31
|
+
expect(hasContainerCodexAuth({ CODEX_API_KEY: "sk-test" })).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns true when ~/.codex/auth.json exists on host", () => {
|
|
35
|
+
const codexDir = join(tempHome, ".codex");
|
|
36
|
+
mkdirSync(codexDir, { recursive: true });
|
|
37
|
+
writeFileSync(join(codexDir, "auth.json"), '{"token":"x"}');
|
|
38
|
+
|
|
39
|
+
expect(hasContainerCodexAuth()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false when neither env vars nor auth files are present", () => {
|
|
43
|
+
expect(hasContainerCodexAuth()).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns false when only unrelated env vars are present", () => {
|
|
47
|
+
// Claude auth vars should NOT satisfy Codex auth
|
|
48
|
+
expect(hasContainerCodexAuth({ ANTHROPIC_API_KEY: "sk-ant-test" })).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true when Codex running inside a container has a plausible auth source:
|
|
7
|
+
* - explicit OpenAI auth env vars, or
|
|
8
|
+
* - known auth files under ~/.codex that can be copied into the container.
|
|
9
|
+
*/
|
|
10
|
+
export function hasContainerCodexAuth(envVars?: Record<string, string>): boolean {
|
|
11
|
+
if (
|
|
12
|
+
!!envVars?.OPENAI_API_KEY
|
|
13
|
+
|| !!envVars?.CODEX_API_KEY
|
|
14
|
+
) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
19
|
+
const candidates = [
|
|
20
|
+
join(home, ".codex", "auth.json"),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
return candidates.some((p) => existsSync(p));
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_COMPANION_CODEX_HOME,
|
|
6
|
+
getLegacyCodexHome,
|
|
7
|
+
resolveCompanionCodexHome,
|
|
8
|
+
resolveCompanionCodexSessionHome,
|
|
9
|
+
} from "./codex-home.js";
|
|
10
|
+
|
|
11
|
+
describe("codex-home", () => {
|
|
12
|
+
it("DEFAULT_COMPANION_CODEX_HOME points to ~/.companion/codex-home", () => {
|
|
13
|
+
expect(DEFAULT_COMPANION_CODEX_HOME).toBe(
|
|
14
|
+
join(homedir(), ".companion", "codex-home"),
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("getLegacyCodexHome returns ~/.codex", () => {
|
|
19
|
+
expect(getLegacyCodexHome()).toBe(join(homedir(), ".codex"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("resolveCompanionCodexHome returns default when no explicit path given", () => {
|
|
23
|
+
expect(resolveCompanionCodexHome()).toBe(DEFAULT_COMPANION_CODEX_HOME);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolveCompanionCodexHome uses explicit path when provided", () => {
|
|
27
|
+
const custom = "/tmp/my-codex-home";
|
|
28
|
+
expect(resolveCompanionCodexHome(custom)).toBe(custom);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Regression: resolveCompanionCodexHome must NOT read process.env.CODEX_HOME
|
|
32
|
+
// because that points to the user's global ~/.codex and would break per-session isolation.
|
|
33
|
+
it("resolveCompanionCodexHome ignores process.env.CODEX_HOME", () => {
|
|
34
|
+
const original = process.env.CODEX_HOME;
|
|
35
|
+
try {
|
|
36
|
+
process.env.CODEX_HOME = "/tmp/global-codex";
|
|
37
|
+
expect(resolveCompanionCodexHome()).toBe(DEFAULT_COMPANION_CODEX_HOME);
|
|
38
|
+
} finally {
|
|
39
|
+
if (original === undefined) {
|
|
40
|
+
delete process.env.CODEX_HOME;
|
|
41
|
+
} else {
|
|
42
|
+
process.env.CODEX_HOME = original;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("resolveCompanionCodexSessionHome appends sessionId to base", () => {
|
|
48
|
+
const sessionId = "abc-123";
|
|
49
|
+
expect(resolveCompanionCodexSessionHome(sessionId)).toBe(
|
|
50
|
+
join(DEFAULT_COMPANION_CODEX_HOME, sessionId),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolveCompanionCodexSessionHome uses explicit path", () => {
|
|
55
|
+
const custom = "/tmp/my-codex-home";
|
|
56
|
+
const sessionId = "xyz-789";
|
|
57
|
+
expect(resolveCompanionCodexSessionHome(sessionId, custom)).toBe(
|
|
58
|
+
join(custom, sessionId),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_COMPANION_CODEX_HOME = join(
|
|
6
|
+
COMPANION_HOME,
|
|
7
|
+
"codex-home",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export function getLegacyCodexHome(): string {
|
|
11
|
+
return join(homedir(), ".codex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveCompanionCodexHome(explicitCodexHome?: string): string {
|
|
15
|
+
// Intentionally do NOT fall back to process.env.CODEX_HOME here.
|
|
16
|
+
// That env var points to the user's global Codex home (~/.codex), which
|
|
17
|
+
// would break per-session isolation by nesting session dirs inside it.
|
|
18
|
+
return resolve(explicitCodexHome || DEFAULT_COMPANION_CODEX_HOME);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveCompanionCodexSessionHome(
|
|
22
|
+
sessionId: string,
|
|
23
|
+
explicitCodexHome?: string,
|
|
24
|
+
): string {
|
|
25
|
+
return join(resolveCompanionCodexHome(explicitCodexHome), sessionId);
|
|
26
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function readSnapshot(relativePath: string): string {
|
|
6
|
+
return readFileSync(resolve(process.cwd(), relativePath), "utf-8");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractMethods(tsSource: string): string[] {
|
|
10
|
+
const methods = [...tsSource.matchAll(/"method": "([^"]+)"/g)].map((m) => m[1]);
|
|
11
|
+
return Array.from(new Set(methods));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("Codex protocol compatibility (offline snapshot)", () => {
|
|
15
|
+
it("includes all Codex notifications handled by codex-adapter", () => {
|
|
16
|
+
const serverNotification = readSnapshot("server/protocol/codex-upstream/ServerNotification.ts.txt");
|
|
17
|
+
const methods = extractMethods(serverNotification);
|
|
18
|
+
|
|
19
|
+
for (const method of [
|
|
20
|
+
"item/started",
|
|
21
|
+
"item/agentMessage/delta",
|
|
22
|
+
"item/commandExecution/outputDelta",
|
|
23
|
+
"item/fileChange/outputDelta",
|
|
24
|
+
"item/reasoning/textDelta",
|
|
25
|
+
"item/reasoning/summaryTextDelta",
|
|
26
|
+
"item/reasoning/summaryPartAdded",
|
|
27
|
+
"item/mcpToolCall/progress",
|
|
28
|
+
"item/plan/delta",
|
|
29
|
+
"item/completed",
|
|
30
|
+
"rawResponseItem/completed",
|
|
31
|
+
"turn/started",
|
|
32
|
+
"turn/completed",
|
|
33
|
+
"turn/plan/updated",
|
|
34
|
+
"turn/diff/updated",
|
|
35
|
+
"thread/started",
|
|
36
|
+
"thread/tokenUsage/updated",
|
|
37
|
+
"account/updated",
|
|
38
|
+
"account/rateLimits/updated",
|
|
39
|
+
"account/login/completed",
|
|
40
|
+
]) {
|
|
41
|
+
expect(methods).toContain(method);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("includes all client methods used by codex-adapter", () => {
|
|
46
|
+
const clientRequest = readSnapshot("server/protocol/codex-upstream/ClientRequest.ts.txt");
|
|
47
|
+
const methods = extractMethods(clientRequest);
|
|
48
|
+
|
|
49
|
+
for (const method of [
|
|
50
|
+
"initialize",
|
|
51
|
+
"thread/start",
|
|
52
|
+
"thread/resume",
|
|
53
|
+
"turn/start",
|
|
54
|
+
"turn/interrupt",
|
|
55
|
+
"account/rateLimits/read",
|
|
56
|
+
]) {
|
|
57
|
+
expect(methods).toContain(method);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("includes the client initialized notification used by codex-adapter", () => {
|
|
62
|
+
const clientNotification = readSnapshot("server/protocol/codex-upstream/ClientNotification.ts.txt");
|
|
63
|
+
const methods = extractMethods(clientNotification);
|
|
64
|
+
expect(methods).toContain("initialized");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("includes all server requests handled by codex-adapter", () => {
|
|
68
|
+
const serverRequest = readSnapshot("server/protocol/codex-upstream/ServerRequest.ts.txt");
|
|
69
|
+
const methods = extractMethods(serverRequest);
|
|
70
|
+
|
|
71
|
+
for (const method of [
|
|
72
|
+
"item/commandExecution/requestApproval",
|
|
73
|
+
"item/fileChange/requestApproval",
|
|
74
|
+
"item/tool/requestUserInput",
|
|
75
|
+
"item/tool/call",
|
|
76
|
+
"applyPatchApproval",
|
|
77
|
+
"execCommandApproval",
|
|
78
|
+
"account/chatgptAuthTokens/refresh",
|
|
79
|
+
]) {
|
|
80
|
+
expect(methods).toContain(method);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps DynamicToolCallParams shape expected by the adapter", () => {
|
|
85
|
+
const paramsType = readSnapshot("server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt");
|
|
86
|
+
for (const field of ["threadId", "turnId", "callId", "tool", "arguments"]) {
|
|
87
|
+
expect(paramsType).toContain(`${field}:`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps DynamicToolCallResponse shape expected by the adapter", () => {
|
|
92
|
+
const responseType = readSnapshot("server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt");
|
|
93
|
+
expect(responseType).toContain("contentItems:");
|
|
94
|
+
expect(responseType).toContain("success: boolean");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function readFile(relativePath: string): string {
|
|
6
|
+
return readFileSync(resolve(process.cwd(), relativePath), "utf-8");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractMethods(tsSource: string): Set<string> {
|
|
10
|
+
return new Set([...tsSource.matchAll(/"method": "([^"]+)"/g)].map((m) => m[1]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractCaseMethods(source: string, start: string, end: string): Set<string> {
|
|
14
|
+
const afterStart = source.split(start)[1];
|
|
15
|
+
if (!afterStart) return new Set();
|
|
16
|
+
const block = afterStart.split(end)[0] || "";
|
|
17
|
+
return new Set([...block.matchAll(/case "([^"]+)":/g)].map((m) => m[1]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("Codex adapter method drift vs upstream protocol snapshot", () => {
|
|
21
|
+
it("keeps handled methods aligned with the upstream protocol (or explicit legacy allowlist)", () => {
|
|
22
|
+
const adapter = readFile("server/codex-adapter.ts");
|
|
23
|
+
|
|
24
|
+
const handledNotifications = extractCaseMethods(
|
|
25
|
+
adapter,
|
|
26
|
+
"private handleNotification(method: string, params: Record<string, unknown>): void {",
|
|
27
|
+
"private handleRequest(method: string, id: number, params: Record<string, unknown>): void {",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const handledRequests = extractCaseMethods(
|
|
31
|
+
adapter,
|
|
32
|
+
"private handleRequest(method: string, id: number, params: Record<string, unknown>): void {",
|
|
33
|
+
"private handleCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const calledClientMethods = new Set(
|
|
37
|
+
[...adapter.matchAll(/this\.transport\.(?:call|notify)\("([^"]+)"/g)].map((m) => m[1]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const upstreamServerNotifications = extractMethods(readFile("server/protocol/codex-upstream/ServerNotification.ts.txt"));
|
|
41
|
+
const upstreamServerRequests = extractMethods(readFile("server/protocol/codex-upstream/ServerRequest.ts.txt"));
|
|
42
|
+
const upstreamClientRequests = extractMethods(readFile("server/protocol/codex-upstream/ClientRequest.ts.txt"));
|
|
43
|
+
const upstreamClientNotifications = extractMethods(readFile("server/protocol/codex-upstream/ClientNotification.ts.txt"));
|
|
44
|
+
|
|
45
|
+
const legacyNotifications = new Set([
|
|
46
|
+
"item/updated",
|
|
47
|
+
// Legacy alias still observed in recordings; upstream snapshot currently
|
|
48
|
+
// models the same payload under item/reasoning/textDelta.
|
|
49
|
+
"item/reasoning/delta",
|
|
50
|
+
// Status notification observed in production logs but not yet present in
|
|
51
|
+
// the pinned upstream snapshot files.
|
|
52
|
+
"thread/status/changed",
|
|
53
|
+
"codex/event/stream_error",
|
|
54
|
+
"codex/event/error",
|
|
55
|
+
"codex/event/token_count",
|
|
56
|
+
"codex/event/agent_message_delta",
|
|
57
|
+
"codex/event/agent_message_content_delta",
|
|
58
|
+
"codex/event/reasoning_content_delta",
|
|
59
|
+
"codex/event/agent_message",
|
|
60
|
+
"codex/event/item_started",
|
|
61
|
+
"codex/event/item_completed",
|
|
62
|
+
"codex/event/exec_command_begin",
|
|
63
|
+
"codex/event/exec_command_output_delta",
|
|
64
|
+
"codex/event/exec_command_end",
|
|
65
|
+
"codex/event/turn_diff",
|
|
66
|
+
"codex/event/terminal_interaction",
|
|
67
|
+
"codex/event/patch_apply_begin",
|
|
68
|
+
"codex/event/patch_apply_end",
|
|
69
|
+
"codex/event/user_message",
|
|
70
|
+
"codex/event/task_started",
|
|
71
|
+
"codex/event/task_complete",
|
|
72
|
+
"codex/event/mcp_startup_complete",
|
|
73
|
+
"codex/event/context_compacted",
|
|
74
|
+
"codex/event/agent_reasoning",
|
|
75
|
+
"codex/event/agent_reasoning_delta",
|
|
76
|
+
"codex/event/agent_reasoning_section_break",
|
|
77
|
+
// Bare "error" notification for transient stream disconnections.
|
|
78
|
+
"error",
|
|
79
|
+
// Per-server MCP startup progress updates.
|
|
80
|
+
"mcpServer/startupStatus/updated",
|
|
81
|
+
// Context compaction event (v2 form of codex/event/context_compacted).
|
|
82
|
+
"thread/compacted",
|
|
83
|
+
// Informational warnings from Codex runtime.
|
|
84
|
+
"configWarning",
|
|
85
|
+
"deprecationNotice",
|
|
86
|
+
"codex/event/deprecation_notice",
|
|
87
|
+
// Legacy event variants for MCP startup, turn abort, image viewing, web search.
|
|
88
|
+
"codex/event/mcp_startup_update",
|
|
89
|
+
"codex/event/turn_aborted",
|
|
90
|
+
"codex/event/view_image_tool_call",
|
|
91
|
+
"codex/event/web_search_begin",
|
|
92
|
+
"codex/event/web_search_end",
|
|
93
|
+
// Companion-internal notification emitted by codex-ws-proxy.cjs on
|
|
94
|
+
// WebSocket reconnection — not part of the upstream Codex protocol.
|
|
95
|
+
"companion/wsReconnected",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const legacyServerRequests = new Set([
|
|
99
|
+
"item/mcpToolCall/requestApproval",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
for (const method of handledNotifications) {
|
|
103
|
+
expect(
|
|
104
|
+
upstreamServerNotifications.has(method) || legacyNotifications.has(method),
|
|
105
|
+
`Unhandled by upstream snapshot (notification): ${method}`,
|
|
106
|
+
).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const method of handledRequests) {
|
|
110
|
+
expect(
|
|
111
|
+
upstreamServerRequests.has(method) || legacyServerRequests.has(method),
|
|
112
|
+
`Unhandled by upstream snapshot (server request): ${method}`,
|
|
113
|
+
).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const method of calledClientMethods) {
|
|
117
|
+
expect(
|
|
118
|
+
upstreamClientRequests.has(method) || upstreamClientNotifications.has(method),
|
|
119
|
+
`Unhandled by upstream snapshot (client method): ${method}`,
|
|
120
|
+
).toBe(true);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Bridges newline-delimited JSON on stdin/stdout to WebSocket text frames for
|
|
5
|
+
// Codex app-server. Runs in real Node (not Bun) so the `ws` package handles the
|
|
6
|
+
// Codex Rust server handshake correctly with perMessageDeflate disabled.
|
|
7
|
+
|
|
8
|
+
const readline = require("node:readline");
|
|
9
|
+
const WebSocket = require("ws");
|
|
10
|
+
|
|
11
|
+
const url = process.argv[2];
|
|
12
|
+
const timeoutMs = Number(process.argv[3] || "30000");
|
|
13
|
+
const pongTimeoutArg = process.argv[4];
|
|
14
|
+
|
|
15
|
+
if (!url) {
|
|
16
|
+
process.stderr.write("[codex-ws-proxy] Missing URL argument\n");
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let ws = null;
|
|
21
|
+
let opened = false;
|
|
22
|
+
let closed = false;
|
|
23
|
+
let exiting = false;
|
|
24
|
+
let queue = [];
|
|
25
|
+
let connectAttempt = 0;
|
|
26
|
+
const startedAt = Date.now();
|
|
27
|
+
|
|
28
|
+
// Reconnection state — after a successful initial connection, transient
|
|
29
|
+
// WebSocket drops are retried with exponential backoff before giving up.
|
|
30
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
31
|
+
const RECONNECT_BASE_MS = 200;
|
|
32
|
+
const RECONNECT_MAX_MS = 5000;
|
|
33
|
+
let reconnecting = false;
|
|
34
|
+
let reconnectAttempt = 0;
|
|
35
|
+
|
|
36
|
+
// Heartbeat — detect zombie WebSocket connections where the TCP socket is open
|
|
37
|
+
// but the remote Codex process has stopped responding.
|
|
38
|
+
const PING_INTERVAL_MS = 30000;
|
|
39
|
+
const PONG_TIMEOUT_MS = pongTimeoutArg ? Number(pongTimeoutArg) : 30000;
|
|
40
|
+
let pingTimer = null;
|
|
41
|
+
let pongTimer = null;
|
|
42
|
+
|
|
43
|
+
function log(msg) {
|
|
44
|
+
process.stderr.write(`[codex-ws-proxy] ${msg}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function startHeartbeat() {
|
|
48
|
+
stopHeartbeat();
|
|
49
|
+
pingTimer = setInterval(() => {
|
|
50
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
51
|
+
if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; }
|
|
52
|
+
ws.ping();
|
|
53
|
+
pongTimer = setTimeout(() => {
|
|
54
|
+
log("Pong timeout — connection appears dead");
|
|
55
|
+
try { ws.terminate(); } catch {}
|
|
56
|
+
// terminate() fires the close event which triggers scheduleReconnect
|
|
57
|
+
}, PONG_TIMEOUT_MS);
|
|
58
|
+
}, PING_INTERVAL_MS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stopHeartbeat() {
|
|
62
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
63
|
+
if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decodeMessageData(data) {
|
|
67
|
+
if (typeof data === "string") return data;
|
|
68
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
69
|
+
if (Array.isArray(data)) return Buffer.concat(data.map((x) => Buffer.from(x))).toString("utf8");
|
|
70
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
71
|
+
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
72
|
+
return String(data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function flushQueue() {
|
|
76
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || queue.length === 0) return;
|
|
77
|
+
for (const line of queue) {
|
|
78
|
+
ws.send(line);
|
|
79
|
+
}
|
|
80
|
+
queue = [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function failAndExit(message, code = 1) {
|
|
84
|
+
if (exiting) return;
|
|
85
|
+
exiting = true;
|
|
86
|
+
stopHeartbeat();
|
|
87
|
+
log(message);
|
|
88
|
+
try { if (ws) ws.close(); } catch {}
|
|
89
|
+
process.exit(code);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Attempt to reconnect after a post-open WebSocket drop.
|
|
94
|
+
* Uses exponential backoff up to MAX_RECONNECT_ATTEMPTS before giving up.
|
|
95
|
+
*/
|
|
96
|
+
function scheduleReconnect(reason) {
|
|
97
|
+
if (closed || exiting) return;
|
|
98
|
+
stopHeartbeat();
|
|
99
|
+
reconnectAttempt++;
|
|
100
|
+
if (reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
101
|
+
failAndExit(`WebSocket reconnection failed after ${MAX_RECONNECT_ATTEMPTS} attempts (last: ${reason})`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reconnecting = true;
|
|
106
|
+
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt - 1), RECONNECT_MAX_MS);
|
|
107
|
+
log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt}/${MAX_RECONNECT_ATTEMPTS}) — ${reason}`);
|
|
108
|
+
setTimeout(connect, delay);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function connect() {
|
|
112
|
+
if (closed || exiting) return;
|
|
113
|
+
|
|
114
|
+
// During initial connection (before first successful open), enforce timeout.
|
|
115
|
+
if (!opened) {
|
|
116
|
+
connectAttempt += 1;
|
|
117
|
+
const elapsed = Date.now() - startedAt;
|
|
118
|
+
if (elapsed > timeoutMs) {
|
|
119
|
+
failAndExit(`Failed to connect within ${timeoutMs}ms`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ws = new WebSocket(url, { perMessageDeflate: false });
|
|
125
|
+
|
|
126
|
+
ws.once("open", () => {
|
|
127
|
+
if (!opened) {
|
|
128
|
+
opened = true;
|
|
129
|
+
}
|
|
130
|
+
const wasReconnect = reconnecting;
|
|
131
|
+
if (reconnecting) {
|
|
132
|
+
log(`Reconnected successfully (attempt ${reconnectAttempt})`);
|
|
133
|
+
reconnecting = false;
|
|
134
|
+
reconnectAttempt = 0;
|
|
135
|
+
}
|
|
136
|
+
startHeartbeat();
|
|
137
|
+
flushQueue();
|
|
138
|
+
// Notify the adapter AFTER flushing any buffered messages so stale Codex
|
|
139
|
+
// responses from the pre-drop session are delivered before the adapter
|
|
140
|
+
// rejects all pending calls and cleans up.
|
|
141
|
+
if (wasReconnect) {
|
|
142
|
+
const reconnectNotification = JSON.stringify({
|
|
143
|
+
method: "companion/wsReconnected",
|
|
144
|
+
params: {},
|
|
145
|
+
});
|
|
146
|
+
process.stdout.write(reconnectNotification + "\n");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ws.on("message", (data) => {
|
|
151
|
+
const raw = decodeMessageData(data);
|
|
152
|
+
// stdout is protocol channel: ONLY write payload lines
|
|
153
|
+
process.stdout.write(raw + "\n");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
ws.on("pong", () => {
|
|
157
|
+
// Heartbeat response received — connection is alive
|
|
158
|
+
if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
ws.once("close", (code, reason) => {
|
|
162
|
+
stopHeartbeat();
|
|
163
|
+
if (closed || exiting) return;
|
|
164
|
+
const why = reason ? ` reason=${reason}` : "";
|
|
165
|
+
// If connection closes before we ever opened, keep retrying until timeout.
|
|
166
|
+
if (!opened) {
|
|
167
|
+
setTimeout(connect, Math.min(100 * connectAttempt, 500));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Post-open close — attempt reconnection with backoff
|
|
171
|
+
scheduleReconnect(`WebSocket closed (code=${code}${why})`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
ws.once("error", (err) => {
|
|
175
|
+
if (closed || exiting) return;
|
|
176
|
+
// Retry during startup; after a successful connection, use reconnect logic.
|
|
177
|
+
if (!opened) {
|
|
178
|
+
setTimeout(connect, Math.min(100 * connectAttempt, 500));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Post-open error — attempt reconnection with backoff
|
|
182
|
+
scheduleReconnect(`WebSocket error: ${err && err.message ? err.message : String(err)}`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rl = readline.createInterface({
|
|
187
|
+
input: process.stdin,
|
|
188
|
+
crlfDelay: Infinity,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
rl.on("line", (line) => {
|
|
192
|
+
if (closed || exiting) return;
|
|
193
|
+
if (!line) return;
|
|
194
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
195
|
+
queue.push(line);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
ws.send(line);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
rl.on("close", () => {
|
|
202
|
+
closed = true;
|
|
203
|
+
stopHeartbeat();
|
|
204
|
+
try {
|
|
205
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
206
|
+
ws.close();
|
|
207
|
+
}
|
|
208
|
+
} catch {}
|
|
209
|
+
process.exit(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
process.on("SIGINT", () => {
|
|
213
|
+
closed = true;
|
|
214
|
+
stopHeartbeat();
|
|
215
|
+
try { if (ws) ws.close(); } catch {}
|
|
216
|
+
process.exit(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
process.on("SIGTERM", () => {
|
|
220
|
+
closed = true;
|
|
221
|
+
stopHeartbeat();
|
|
222
|
+
try { if (ws) ws.close(); } catch {}
|
|
223
|
+
process.exit(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
connect();
|