@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,225 @@
|
|
|
1
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface UsageLimits {
|
|
7
|
+
five_hour: { utilization: number; resets_at: string | null } | null;
|
|
8
|
+
seven_day: { utilization: number; resets_at: string | null } | null;
|
|
9
|
+
extra_usage: {
|
|
10
|
+
is_enabled: boolean;
|
|
11
|
+
monthly_limit: number;
|
|
12
|
+
used_credits: number;
|
|
13
|
+
utilization: number | null;
|
|
14
|
+
} | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
18
|
+
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
19
|
+
|
|
20
|
+
// In-memory cache (60s TTL)
|
|
21
|
+
const CACHE_DURATION_MS = 60 * 1000;
|
|
22
|
+
let cache: { data: UsageLimits; timestamp: number } | null = null;
|
|
23
|
+
|
|
24
|
+
interface OAuthCredentials {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RawCredentials {
|
|
32
|
+
raw: string;
|
|
33
|
+
parsed: Record<string, unknown>;
|
|
34
|
+
oauth: OAuthCredentials;
|
|
35
|
+
sourcePath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Credential file candidates - matches claude-container-auth.ts
|
|
39
|
+
const CREDENTIAL_FILE_NAMES = [
|
|
40
|
+
".credentials.json",
|
|
41
|
+
"auth.json",
|
|
42
|
+
".auth.json",
|
|
43
|
+
"credentials.json",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function readCredentialsFromFile(): RawCredentials | null {
|
|
47
|
+
const home =
|
|
48
|
+
process.env.USERPROFILE || process.env.HOME || homedir() || "";
|
|
49
|
+
const claudeDir = join(home, ".claude");
|
|
50
|
+
|
|
51
|
+
for (const fileName of CREDENTIAL_FILE_NAMES) {
|
|
52
|
+
const credPath = join(claudeDir, fileName);
|
|
53
|
+
if (!existsSync(credPath)) continue;
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(credPath, "utf-8");
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (!parsed?.claudeAiOauth?.accessToken) continue;
|
|
58
|
+
return { raw, parsed, oauth: parsed.claudeAiOauth, sourcePath: credPath };
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readRawCredentials(): RawCredentials | null {
|
|
67
|
+
try {
|
|
68
|
+
// macOS: use Keychain via security command
|
|
69
|
+
if (process.platform === "darwin") {
|
|
70
|
+
const raw = execSync(
|
|
71
|
+
'security find-generic-password -s "Claude Code-credentials" -w',
|
|
72
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
73
|
+
).trim();
|
|
74
|
+
|
|
75
|
+
const decoded = raw.startsWith("{")
|
|
76
|
+
? raw
|
|
77
|
+
: Buffer.from(raw, "hex").toString("utf-8");
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(decoded);
|
|
80
|
+
if (!parsed?.claudeAiOauth?.accessToken) return null;
|
|
81
|
+
return { raw: decoded, parsed, oauth: parsed.claudeAiOauth };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Windows, Linux, Docker, and other platforms: read from credential files
|
|
85
|
+
return readCredentialsFromFile();
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeCredentials(creds: Record<string, unknown>, sourcePath?: string): void {
|
|
92
|
+
try {
|
|
93
|
+
const json = JSON.stringify(creds);
|
|
94
|
+
if (process.platform === "darwin") {
|
|
95
|
+
execFileSync(
|
|
96
|
+
"security",
|
|
97
|
+
["add-generic-password", "-U", "-s", "Claude Code-credentials", "-a", "Claude Code", "-w", json],
|
|
98
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
// Write back to the same file that was read, or default to .credentials.json
|
|
102
|
+
const credPath = sourcePath ?? join(
|
|
103
|
+
process.env.USERPROFILE || process.env.HOME || homedir() || "",
|
|
104
|
+
".claude",
|
|
105
|
+
".credentials.json",
|
|
106
|
+
);
|
|
107
|
+
writeFileSync(credPath, json, "utf-8");
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function refreshAccessToken(refreshToken: string): Promise<{
|
|
115
|
+
accessToken: string;
|
|
116
|
+
refreshToken: string;
|
|
117
|
+
expiresIn: number;
|
|
118
|
+
} | null> {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
123
|
+
body: new URLSearchParams({
|
|
124
|
+
grant_type: "refresh_token",
|
|
125
|
+
refresh_token: refreshToken,
|
|
126
|
+
client_id: OAUTH_CLIENT_ID,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) return null;
|
|
130
|
+
const data = await res.json();
|
|
131
|
+
return {
|
|
132
|
+
accessToken: data.access_token,
|
|
133
|
+
refreshToken: data.refresh_token,
|
|
134
|
+
expiresIn: data.expires_in,
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getCredentials(): string | null {
|
|
142
|
+
const creds = readRawCredentials();
|
|
143
|
+
return creds?.oauth.accessToken ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function getValidAccessToken(): Promise<string | null> {
|
|
147
|
+
const creds = readRawCredentials();
|
|
148
|
+
if (!creds) return null;
|
|
149
|
+
|
|
150
|
+
const { oauth } = creds;
|
|
151
|
+
|
|
152
|
+
// Token still valid (with 5min buffer)
|
|
153
|
+
if (oauth.expiresAt && Date.now() < oauth.expiresAt - 5 * 60 * 1000) {
|
|
154
|
+
return oauth.accessToken;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Token expired - try to refresh
|
|
158
|
+
if (!oauth.refreshToken) return null;
|
|
159
|
+
|
|
160
|
+
const refreshed = await refreshAccessToken(oauth.refreshToken);
|
|
161
|
+
if (!refreshed) return null;
|
|
162
|
+
|
|
163
|
+
// Update stored credentials
|
|
164
|
+
creds.parsed.claudeAiOauth = {
|
|
165
|
+
...oauth,
|
|
166
|
+
accessToken: refreshed.accessToken,
|
|
167
|
+
refreshToken: refreshed.refreshToken,
|
|
168
|
+
expiresAt: Date.now() + refreshed.expiresIn * 1000,
|
|
169
|
+
};
|
|
170
|
+
writeCredentials(creds.parsed, creds.sourcePath);
|
|
171
|
+
|
|
172
|
+
return refreshed.accessToken;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchUsageLimits(
|
|
176
|
+
token: string,
|
|
177
|
+
): Promise<UsageLimits | null> {
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
180
|
+
method: "GET",
|
|
181
|
+
headers: {
|
|
182
|
+
Accept: "application/json",
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
"User-Agent": "claude-code/2.1.39",
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!response.ok) return null;
|
|
191
|
+
|
|
192
|
+
const data = await response.json();
|
|
193
|
+
return {
|
|
194
|
+
five_hour: data.five_hour || null,
|
|
195
|
+
seven_day: data.seven_day || null,
|
|
196
|
+
extra_usage: data.extra_usage || null,
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function getUsageLimits(): Promise<UsageLimits> {
|
|
204
|
+
const empty: UsageLimits = {
|
|
205
|
+
five_hour: null,
|
|
206
|
+
seven_day: null,
|
|
207
|
+
extra_usage: null,
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
if (cache && Date.now() - cache.timestamp < CACHE_DURATION_MS) {
|
|
211
|
+
return cache.data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = await getValidAccessToken();
|
|
215
|
+
if (!token) return empty;
|
|
216
|
+
|
|
217
|
+
const limits = await fetchUsageLimits(token);
|
|
218
|
+
if (!limits) return empty;
|
|
219
|
+
|
|
220
|
+
cache = { data: limits, timestamp: Date.now() };
|
|
221
|
+
return limits;
|
|
222
|
+
} catch {
|
|
223
|
+
return empty;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
rmSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import type { WorktreeMapping } from "./worktree-tracker.js";
|
|
11
|
+
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
let WorktreeTracker: typeof import("./worktree-tracker.js").WorktreeTracker;
|
|
14
|
+
|
|
15
|
+
const mockHomedir = vi.hoisted(() => {
|
|
16
|
+
let dir = "";
|
|
17
|
+
return {
|
|
18
|
+
get: () => dir,
|
|
19
|
+
set: (d: string) => {
|
|
20
|
+
dir = d;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
26
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
homedir: () => mockHomedir.get(),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
tempDir = mkdtempSync(join(tmpdir(), "wt-test-"));
|
|
35
|
+
mockHomedir.set(tempDir);
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
const mod = await import("./worktree-tracker.js");
|
|
38
|
+
WorktreeTracker = mod.WorktreeTracker;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function makeMapping(overrides: Partial<WorktreeMapping> = {}): WorktreeMapping {
|
|
46
|
+
return {
|
|
47
|
+
sessionId: "session-1",
|
|
48
|
+
repoRoot: "/repo",
|
|
49
|
+
branch: "feat-1",
|
|
50
|
+
worktreePath: "/worktrees/feat-1",
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function trackerFilePath(): string {
|
|
57
|
+
return join(tempDir, ".companion", "worktrees.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readTrackerFile(): WorktreeMapping[] {
|
|
61
|
+
return JSON.parse(readFileSync(trackerFilePath(), "utf-8"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Constructor ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("WorktreeTracker", () => {
|
|
67
|
+
describe("constructor", () => {
|
|
68
|
+
it("initializes with empty mappings when no file exists", () => {
|
|
69
|
+
const tracker = new WorktreeTracker();
|
|
70
|
+
expect(tracker.getBySession("anything")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("loads existing mappings from disk", () => {
|
|
74
|
+
const mapping = makeMapping();
|
|
75
|
+
mkdirSync(join(tempDir, ".companion"), { recursive: true });
|
|
76
|
+
writeFileSync(trackerFilePath(), JSON.stringify([mapping]));
|
|
77
|
+
|
|
78
|
+
const tracker = new WorktreeTracker();
|
|
79
|
+
expect(tracker.getBySession("session-1")).toEqual(mapping);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles corrupt JSON gracefully", () => {
|
|
83
|
+
mkdirSync(join(tempDir, ".companion"), { recursive: true });
|
|
84
|
+
writeFileSync(trackerFilePath(), "NOT VALID JSON {{{");
|
|
85
|
+
|
|
86
|
+
const tracker = new WorktreeTracker();
|
|
87
|
+
expect(tracker.getBySession("anything")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── addMapping ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("addMapping", () => {
|
|
94
|
+
it("persists mapping to disk", () => {
|
|
95
|
+
const tracker = new WorktreeTracker();
|
|
96
|
+
const mapping = makeMapping();
|
|
97
|
+
tracker.addMapping(mapping);
|
|
98
|
+
|
|
99
|
+
const onDisk = readTrackerFile();
|
|
100
|
+
expect(onDisk).toHaveLength(1);
|
|
101
|
+
expect(onDisk[0]).toEqual(mapping);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("replaces existing mapping for same sessionId", () => {
|
|
105
|
+
const tracker = new WorktreeTracker();
|
|
106
|
+
const original = makeMapping({ branch: "feat-1" });
|
|
107
|
+
tracker.addMapping(original);
|
|
108
|
+
|
|
109
|
+
const updated = makeMapping({ branch: "feat-2", worktreePath: "/worktrees/feat-2" });
|
|
110
|
+
tracker.addMapping(updated);
|
|
111
|
+
|
|
112
|
+
const onDisk = readTrackerFile();
|
|
113
|
+
expect(onDisk).toHaveLength(1);
|
|
114
|
+
expect(onDisk[0].branch).toBe("feat-2");
|
|
115
|
+
expect(onDisk[0].worktreePath).toBe("/worktrees/feat-2");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("allows multiple mappings with different sessionIds", () => {
|
|
119
|
+
const tracker = new WorktreeTracker();
|
|
120
|
+
tracker.addMapping(makeMapping({ sessionId: "s1" }));
|
|
121
|
+
tracker.addMapping(makeMapping({ sessionId: "s2" }));
|
|
122
|
+
tracker.addMapping(makeMapping({ sessionId: "s3" }));
|
|
123
|
+
|
|
124
|
+
const onDisk = readTrackerFile();
|
|
125
|
+
expect(onDisk).toHaveLength(3);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── removeBySession ────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("removeBySession", () => {
|
|
132
|
+
it("returns removed mapping and persists deletion", () => {
|
|
133
|
+
const tracker = new WorktreeTracker();
|
|
134
|
+
const mapping = makeMapping();
|
|
135
|
+
tracker.addMapping(mapping);
|
|
136
|
+
|
|
137
|
+
const removed = tracker.removeBySession("session-1");
|
|
138
|
+
expect(removed).toEqual(mapping);
|
|
139
|
+
|
|
140
|
+
const onDisk = readTrackerFile();
|
|
141
|
+
expect(onDisk).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns null for unknown sessionId", () => {
|
|
145
|
+
const tracker = new WorktreeTracker();
|
|
146
|
+
const result = tracker.removeBySession("nonexistent");
|
|
147
|
+
expect(result).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ─── getBySession ───────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("getBySession", () => {
|
|
154
|
+
it("returns mapping when found", () => {
|
|
155
|
+
const tracker = new WorktreeTracker();
|
|
156
|
+
const mapping = makeMapping();
|
|
157
|
+
tracker.addMapping(mapping);
|
|
158
|
+
|
|
159
|
+
expect(tracker.getBySession("session-1")).toEqual(mapping);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null when not found", () => {
|
|
163
|
+
const tracker = new WorktreeTracker();
|
|
164
|
+
expect(tracker.getBySession("nonexistent")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─── getSessionsForWorktree ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("getSessionsForWorktree", () => {
|
|
171
|
+
it("returns all mappings sharing a worktree path", () => {
|
|
172
|
+
const tracker = new WorktreeTracker();
|
|
173
|
+
const sharedPath = "/worktrees/shared";
|
|
174
|
+
tracker.addMapping(makeMapping({ sessionId: "s1", worktreePath: sharedPath }));
|
|
175
|
+
tracker.addMapping(makeMapping({ sessionId: "s2", worktreePath: sharedPath }));
|
|
176
|
+
tracker.addMapping(makeMapping({ sessionId: "s3", worktreePath: "/worktrees/other" }));
|
|
177
|
+
|
|
178
|
+
const results = tracker.getSessionsForWorktree(sharedPath);
|
|
179
|
+
expect(results).toHaveLength(2);
|
|
180
|
+
expect(results.map((r) => r.sessionId).sort()).toEqual(["s1", "s2"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns empty array when no sessions use the worktree", () => {
|
|
184
|
+
const tracker = new WorktreeTracker();
|
|
185
|
+
tracker.addMapping(makeMapping({ worktreePath: "/worktrees/other" }));
|
|
186
|
+
|
|
187
|
+
const results = tracker.getSessionsForWorktree("/worktrees/nonexistent");
|
|
188
|
+
expect(results).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ─── getSessionsForRepo ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe("getSessionsForRepo", () => {
|
|
195
|
+
it("returns all mappings for a repo root", () => {
|
|
196
|
+
const tracker = new WorktreeTracker();
|
|
197
|
+
tracker.addMapping(makeMapping({ sessionId: "s1", repoRoot: "/repo-a", branch: "feat-1" }));
|
|
198
|
+
tracker.addMapping(makeMapping({ sessionId: "s2", repoRoot: "/repo-a", branch: "feat-2" }));
|
|
199
|
+
tracker.addMapping(makeMapping({ sessionId: "s3", repoRoot: "/repo-b", branch: "feat-1" }));
|
|
200
|
+
|
|
201
|
+
const results = tracker.getSessionsForRepo("/repo-a");
|
|
202
|
+
expect(results).toHaveLength(2);
|
|
203
|
+
expect(results.map((r) => r.sessionId).sort()).toEqual(["s1", "s2"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns empty array when no sessions belong to the repo", () => {
|
|
207
|
+
const tracker = new WorktreeTracker();
|
|
208
|
+
const results = tracker.getSessionsForRepo("/nonexistent-repo");
|
|
209
|
+
expect(results).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─── isWorktreeInUse ────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe("isWorktreeInUse", () => {
|
|
216
|
+
it("returns true when another session uses the worktree", () => {
|
|
217
|
+
const tracker = new WorktreeTracker();
|
|
218
|
+
tracker.addMapping(makeMapping({ sessionId: "s1", worktreePath: "/worktrees/feat" }));
|
|
219
|
+
|
|
220
|
+
expect(tracker.isWorktreeInUse("/worktrees/feat")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns false when no session uses the worktree", () => {
|
|
224
|
+
const tracker = new WorktreeTracker();
|
|
225
|
+
expect(tracker.isWorktreeInUse("/worktrees/feat")).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("excludes the specified session from the check", () => {
|
|
229
|
+
const tracker = new WorktreeTracker();
|
|
230
|
+
tracker.addMapping(makeMapping({ sessionId: "s1", worktreePath: "/worktrees/feat" }));
|
|
231
|
+
|
|
232
|
+
expect(tracker.isWorktreeInUse("/worktrees/feat", "s1")).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("returns true when other sessions use it despite excludeSessionId", () => {
|
|
236
|
+
const tracker = new WorktreeTracker();
|
|
237
|
+
tracker.addMapping(makeMapping({ sessionId: "s1", worktreePath: "/worktrees/feat" }));
|
|
238
|
+
tracker.addMapping(makeMapping({ sessionId: "s2", worktreePath: "/worktrees/feat" }));
|
|
239
|
+
|
|
240
|
+
expect(tracker.isWorktreeInUse("/worktrees/feat", "s1")).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface WorktreeMapping {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
repoRoot: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
/** Actual git branch in the worktree (may differ from `branch` for -wt-N branches) */
|
|
17
|
+
actualBranch?: string;
|
|
18
|
+
worktreePath: string;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const TRACKER_PATH = join(COMPANION_HOME, "worktrees.json");
|
|
25
|
+
|
|
26
|
+
// ─── Tracker ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export class WorktreeTracker {
|
|
29
|
+
private mappings: WorktreeMapping[] = [];
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
this.load();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
load(): WorktreeMapping[] {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(TRACKER_PATH)) {
|
|
38
|
+
const raw = readFileSync(TRACKER_PATH, "utf-8");
|
|
39
|
+
this.mappings = JSON.parse(raw) as WorktreeMapping[];
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
this.mappings = [];
|
|
43
|
+
}
|
|
44
|
+
return this.mappings;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private save(): void {
|
|
48
|
+
mkdirSync(dirname(TRACKER_PATH), { recursive: true });
|
|
49
|
+
writeFileSync(TRACKER_PATH, JSON.stringify(this.mappings, null, 2), "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addMapping(mapping: WorktreeMapping): void {
|
|
53
|
+
// Remove any existing mapping for this session
|
|
54
|
+
this.mappings = this.mappings.filter((m) => m.sessionId !== mapping.sessionId);
|
|
55
|
+
this.mappings.push(mapping);
|
|
56
|
+
this.save();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
removeBySession(sessionId: string): WorktreeMapping | null {
|
|
60
|
+
const idx = this.mappings.findIndex((m) => m.sessionId === sessionId);
|
|
61
|
+
if (idx === -1) return null;
|
|
62
|
+
const [removed] = this.mappings.splice(idx, 1);
|
|
63
|
+
this.save();
|
|
64
|
+
return removed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getBySession(sessionId: string): WorktreeMapping | null {
|
|
68
|
+
return this.mappings.find((m) => m.sessionId === sessionId) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getSessionsForWorktree(worktreePath: string): WorktreeMapping[] {
|
|
72
|
+
return this.mappings.filter((m) => m.worktreePath === worktreePath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getSessionsForRepo(repoRoot: string): WorktreeMapping[] {
|
|
76
|
+
return this.mappings.filter((m) => m.repoRoot === repoRoot);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
isWorktreeInUse(worktreePath: string, excludeSessionId?: string): boolean {
|
|
80
|
+
return this.mappings.some(
|
|
81
|
+
(m) => m.worktreePath === worktreePath && m.sessionId !== excludeSessionId,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createToken } from "./middleware/managed-auth.js";
|
|
3
|
+
import { authenticateManagedWebSocket } from "./ws-auth.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SECRET = "test-secret-key-for-ws-auth";
|
|
6
|
+
|
|
7
|
+
describe("authenticateManagedWebSocket", () => {
|
|
8
|
+
const savedSecret = process.env.COMPANION_AUTH_SECRET;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
process.env.COMPANION_AUTH_SECRET = TEST_SECRET;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (savedSecret === undefined) delete process.env.COMPANION_AUTH_SECRET;
|
|
16
|
+
else process.env.COMPANION_AUTH_SECRET = savedSecret;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns 401 when no token is provided", async () => {
|
|
20
|
+
const req = new Request("https://example.com/ws/browser/abc");
|
|
21
|
+
const result = await authenticateManagedWebSocket(req);
|
|
22
|
+
expect(result.ok).toBe(false);
|
|
23
|
+
expect(result.status).toBe(401);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts a valid query token", async () => {
|
|
27
|
+
const token = await createToken(TEST_SECRET, 60);
|
|
28
|
+
const req = new Request(`https://example.com/ws/browser/abc?token=${token}`);
|
|
29
|
+
const result = await authenticateManagedWebSocket(req);
|
|
30
|
+
expect(result.ok).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts a valid cookie token", async () => {
|
|
34
|
+
const token = await createToken(TEST_SECRET, 60);
|
|
35
|
+
const req = new Request("https://example.com/ws/browser/abc", {
|
|
36
|
+
headers: { cookie: `companion_token=${token}` },
|
|
37
|
+
});
|
|
38
|
+
const result = await authenticateManagedWebSocket(req);
|
|
39
|
+
expect(result.ok).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("prefers query token over cookie", async () => {
|
|
43
|
+
const token = await createToken(TEST_SECRET, 60);
|
|
44
|
+
const req = new Request(`https://example.com/ws/browser/abc?token=${token}`, {
|
|
45
|
+
headers: { cookie: "companion_token=bad.token" },
|
|
46
|
+
});
|
|
47
|
+
const result = await authenticateManagedWebSocket(req);
|
|
48
|
+
expect(result.ok).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns 500 when secret is missing", async () => {
|
|
52
|
+
delete process.env.COMPANION_AUTH_SECRET;
|
|
53
|
+
const req = new Request("https://example.com/ws/browser/abc");
|
|
54
|
+
const result = await authenticateManagedWebSocket(req);
|
|
55
|
+
expect(result.ok).toBe(false);
|
|
56
|
+
expect(result.status).toBe(500);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { verifyToken } from "./middleware/managed-auth.js";
|
|
2
|
+
|
|
3
|
+
export interface WsAuthResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status: number;
|
|
6
|
+
body?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getCookie(header: string | null, name: string): string | undefined {
|
|
10
|
+
if (!header) return undefined;
|
|
11
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
12
|
+
return match?.[1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Authenticate browser/terminal WebSocket upgrade requests in managed mode.
|
|
17
|
+
* Accepts token in query param or companion_token cookie (query takes precedence).
|
|
18
|
+
*/
|
|
19
|
+
export async function authenticateManagedWebSocket(req: Request): Promise<WsAuthResult> {
|
|
20
|
+
const secret = process.env.COMPANION_AUTH_SECRET?.trim();
|
|
21
|
+
if (!secret) {
|
|
22
|
+
return { ok: false, status: 500, body: "Server misconfigured" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const url = new URL(req.url);
|
|
26
|
+
const queryToken = url.searchParams.get("token");
|
|
27
|
+
const cookieToken = getCookie(req.headers.get("cookie"), "companion_token");
|
|
28
|
+
const token = queryToken || cookieToken;
|
|
29
|
+
|
|
30
|
+
if (!token) {
|
|
31
|
+
return { ok: false, status: 401, body: "Unauthorized" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const valid = await verifyToken(token, secret);
|
|
35
|
+
if (!valid) {
|
|
36
|
+
return { ok: false, status: 401, body: "Unauthorized" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { ok: true, status: 200 };
|
|
40
|
+
}
|
|
41
|
+
|