@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,162 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
listMappings,
|
|
6
|
+
getMapping,
|
|
7
|
+
upsertMapping,
|
|
8
|
+
removeMapping,
|
|
9
|
+
_resetForTest,
|
|
10
|
+
} from "./linear-project-manager.js";
|
|
11
|
+
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
let filePath: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), "linear-project-manager-test-"));
|
|
17
|
+
filePath = join(tempDir, "linear-projects.json");
|
|
18
|
+
_resetForTest(filePath);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
_resetForTest();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("linear-project-manager", () => {
|
|
27
|
+
it("returns empty list when file is missing", () => {
|
|
28
|
+
expect(listMappings()).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null for unknown repo root", () => {
|
|
32
|
+
expect(getMapping("/unknown/repo")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("upsert creates a new mapping", () => {
|
|
36
|
+
const mapping = upsertMapping("/home/user/project", {
|
|
37
|
+
projectId: "proj-uuid-1",
|
|
38
|
+
projectName: "My Feature",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(mapping.repoRoot).toBe("/home/user/project");
|
|
42
|
+
expect(mapping.projectId).toBe("proj-uuid-1");
|
|
43
|
+
expect(mapping.projectName).toBe("My Feature");
|
|
44
|
+
expect(mapping.createdAt).toBeGreaterThan(0);
|
|
45
|
+
expect(mapping.updatedAt).toBe(mapping.createdAt);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("upsert updates an existing mapping", () => {
|
|
49
|
+
const first = upsertMapping("/home/user/project", {
|
|
50
|
+
projectId: "proj-uuid-1",
|
|
51
|
+
projectName: "My Feature",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const second = upsertMapping("/home/user/project", {
|
|
55
|
+
projectId: "proj-uuid-2",
|
|
56
|
+
projectName: "New Feature",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(second.repoRoot).toBe("/home/user/project");
|
|
60
|
+
expect(second.projectId).toBe("proj-uuid-2");
|
|
61
|
+
expect(second.projectName).toBe("New Feature");
|
|
62
|
+
// createdAt should be preserved from the first mapping
|
|
63
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
64
|
+
expect(second.updatedAt).toBeGreaterThanOrEqual(first.updatedAt);
|
|
65
|
+
|
|
66
|
+
// Should still be only one mapping
|
|
67
|
+
expect(listMappings()).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("getMapping retrieves a stored mapping", () => {
|
|
71
|
+
upsertMapping("/home/user/project", {
|
|
72
|
+
projectId: "proj-uuid-1",
|
|
73
|
+
projectName: "My Feature",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const mapping = getMapping("/home/user/project");
|
|
77
|
+
expect(mapping).not.toBeNull();
|
|
78
|
+
expect(mapping!.projectName).toBe("My Feature");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("removeMapping deletes an entry and returns true", () => {
|
|
82
|
+
upsertMapping("/home/user/project", {
|
|
83
|
+
projectId: "proj-uuid-1",
|
|
84
|
+
projectName: "My Feature",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(removeMapping("/home/user/project")).toBe(true);
|
|
88
|
+
expect(listMappings()).toHaveLength(0);
|
|
89
|
+
expect(getMapping("/home/user/project")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("removeMapping returns false for unknown repo", () => {
|
|
93
|
+
expect(removeMapping("/unknown/repo")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("normalizes trailing slashes on repoRoot", () => {
|
|
97
|
+
upsertMapping("/home/user/project/", {
|
|
98
|
+
projectId: "proj-uuid-1",
|
|
99
|
+
projectName: "My Feature",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Should find it without trailing slash
|
|
103
|
+
expect(getMapping("/home/user/project")).not.toBeNull();
|
|
104
|
+
expect(getMapping("/home/user/project/")).not.toBeNull();
|
|
105
|
+
|
|
106
|
+
// Should be only one mapping
|
|
107
|
+
expect(listMappings()).toHaveLength(1);
|
|
108
|
+
expect(listMappings()[0].repoRoot).toBe("/home/user/project");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("persists to disk and survives reload", () => {
|
|
112
|
+
upsertMapping("/home/user/project", {
|
|
113
|
+
projectId: "proj-uuid-1",
|
|
114
|
+
projectName: "My Feature",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Verify file written to disk
|
|
118
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
119
|
+
expect(raw).toHaveLength(1);
|
|
120
|
+
expect(raw[0].projectName).toBe("My Feature");
|
|
121
|
+
|
|
122
|
+
// Reset and reload from disk
|
|
123
|
+
_resetForTest(filePath);
|
|
124
|
+
const mapping = getMapping("/home/user/project");
|
|
125
|
+
expect(mapping).not.toBeNull();
|
|
126
|
+
expect(mapping!.projectName).toBe("My Feature");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("handles corrupt JSON file gracefully", () => {
|
|
130
|
+
writeFileSync(filePath, "not-json", "utf-8");
|
|
131
|
+
_resetForTest(filePath);
|
|
132
|
+
|
|
133
|
+
expect(listMappings()).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("handles non-array JSON file gracefully", () => {
|
|
137
|
+
writeFileSync(filePath, JSON.stringify({ foo: "bar" }), "utf-8");
|
|
138
|
+
_resetForTest(filePath);
|
|
139
|
+
|
|
140
|
+
expect(listMappings()).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("manages multiple mappings", () => {
|
|
144
|
+
upsertMapping("/repo/alpha", {
|
|
145
|
+
projectId: "p1",
|
|
146
|
+
projectName: "Alpha Project",
|
|
147
|
+
});
|
|
148
|
+
upsertMapping("/repo/beta", {
|
|
149
|
+
projectId: "p2",
|
|
150
|
+
projectName: "Beta Project",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(listMappings()).toHaveLength(2);
|
|
154
|
+
expect(getMapping("/repo/alpha")!.projectName).toBe("Alpha Project");
|
|
155
|
+
expect(getMapping("/repo/beta")!.projectName).toBe("Beta Project");
|
|
156
|
+
|
|
157
|
+
removeMapping("/repo/alpha");
|
|
158
|
+
expect(listMappings()).toHaveLength(1);
|
|
159
|
+
expect(getMapping("/repo/alpha")).toBeNull();
|
|
160
|
+
expect(getMapping("/repo/beta")!.projectName).toBe("Beta Project");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
export interface LinearProjectMapping {
|
|
11
|
+
/** Normalized git repo root path (the key) */
|
|
12
|
+
repoRoot: string;
|
|
13
|
+
/** Linear project UUID */
|
|
14
|
+
projectId: string;
|
|
15
|
+
/** Human-readable project name */
|
|
16
|
+
projectName: string;
|
|
17
|
+
/** When the mapping was created */
|
|
18
|
+
createdAt: number;
|
|
19
|
+
/** When the mapping was last updated */
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PATH = join(COMPANION_HOME, "linear-projects.json");
|
|
24
|
+
|
|
25
|
+
let loaded = false;
|
|
26
|
+
let filePath = DEFAULT_PATH;
|
|
27
|
+
let mappings: LinearProjectMapping[] = [];
|
|
28
|
+
|
|
29
|
+
function normalizeRoot(root: string): string {
|
|
30
|
+
return root.replace(/\/+$/, "") || "/";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureLoaded(): void {
|
|
34
|
+
if (loaded) return;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(filePath)) {
|
|
37
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
38
|
+
if (Array.isArray(raw)) {
|
|
39
|
+
mappings = raw.filter(
|
|
40
|
+
(m: unknown): m is LinearProjectMapping =>
|
|
41
|
+
typeof m === "object" &&
|
|
42
|
+
m !== null &&
|
|
43
|
+
typeof (m as LinearProjectMapping).repoRoot === "string" &&
|
|
44
|
+
typeof (m as LinearProjectMapping).projectId === "string",
|
|
45
|
+
);
|
|
46
|
+
} else {
|
|
47
|
+
mappings = [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
mappings = [];
|
|
52
|
+
}
|
|
53
|
+
loaded = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function persist(): void {
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
58
|
+
writeFileSync(filePath, JSON.stringify(mappings, null, 2), "utf-8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listMappings(): LinearProjectMapping[] {
|
|
62
|
+
ensureLoaded();
|
|
63
|
+
return [...mappings];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getMapping(repoRoot: string): LinearProjectMapping | null {
|
|
67
|
+
ensureLoaded();
|
|
68
|
+
const key = normalizeRoot(repoRoot);
|
|
69
|
+
return mappings.find((m) => m.repoRoot === key) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function upsertMapping(
|
|
73
|
+
repoRoot: string,
|
|
74
|
+
data: { projectId: string; projectName: string },
|
|
75
|
+
): LinearProjectMapping {
|
|
76
|
+
ensureLoaded();
|
|
77
|
+
const key = normalizeRoot(repoRoot);
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const existing = mappings.find((m) => m.repoRoot === key);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.projectId = data.projectId;
|
|
82
|
+
existing.projectName = data.projectName;
|
|
83
|
+
existing.updatedAt = now;
|
|
84
|
+
} else {
|
|
85
|
+
mappings.push({
|
|
86
|
+
repoRoot: key,
|
|
87
|
+
projectId: data.projectId,
|
|
88
|
+
projectName: data.projectName,
|
|
89
|
+
createdAt: now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
persist();
|
|
94
|
+
return mappings.find((m) => m.repoRoot === key)!;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function removeMapping(repoRoot: string): boolean {
|
|
98
|
+
ensureLoaded();
|
|
99
|
+
const key = normalizeRoot(repoRoot);
|
|
100
|
+
const idx = mappings.findIndex((m) => m.repoRoot === key);
|
|
101
|
+
if (idx === -1) return false;
|
|
102
|
+
mappings.splice(idx, 1);
|
|
103
|
+
persist();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function _resetForTest(customPath?: string): void {
|
|
108
|
+
loaded = false;
|
|
109
|
+
filePath = customPath || DEFAULT_PATH;
|
|
110
|
+
mappings = [];
|
|
111
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildLinearSystemPrompt, buildLinearOAuthSystemPrompt } from "./linear-prompt-builder.js";
|
|
3
|
+
|
|
4
|
+
describe("buildLinearSystemPrompt", () => {
|
|
5
|
+
const connection = {
|
|
6
|
+
workspaceName: "Acme Corp",
|
|
7
|
+
viewerName: "Jane Doe",
|
|
8
|
+
viewerEmail: "jane@acme.com",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const issue = {
|
|
12
|
+
identifier: "ENG-42",
|
|
13
|
+
title: "Fix login redirect",
|
|
14
|
+
stateName: "In Progress",
|
|
15
|
+
teamName: "Engineering",
|
|
16
|
+
url: "https://linear.app/acme/issue/ENG-42",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
it("includes workspace info and API instructions", () => {
|
|
20
|
+
// Verifies the core prompt contains workspace context and API usage guidance
|
|
21
|
+
const prompt = buildLinearSystemPrompt(connection);
|
|
22
|
+
expect(prompt).toContain("LINEAR_API_KEY");
|
|
23
|
+
expect(prompt).toContain("Acme Corp");
|
|
24
|
+
expect(prompt).toContain("Jane Doe");
|
|
25
|
+
expect(prompt).toContain("jane@acme.com");
|
|
26
|
+
expect(prompt).toContain("https://api.linear.app/graphql");
|
|
27
|
+
expect(prompt).toContain("Authorization: Bearer $LINEAR_API_KEY");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("includes issue context when provided", () => {
|
|
31
|
+
// When a Linear issue is linked, the prompt should include issue details
|
|
32
|
+
const prompt = buildLinearSystemPrompt(connection, issue);
|
|
33
|
+
expect(prompt).toContain("ENG-42");
|
|
34
|
+
expect(prompt).toContain("Fix login redirect");
|
|
35
|
+
expect(prompt).toContain("In Progress");
|
|
36
|
+
expect(prompt).toContain("Engineering");
|
|
37
|
+
expect(prompt).toContain("https://linear.app/acme/issue/ENG-42");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("omits issue section when no issue provided", () => {
|
|
41
|
+
// Without an issue, the prompt should only contain workspace + API info
|
|
42
|
+
const prompt = buildLinearSystemPrompt(connection);
|
|
43
|
+
expect(prompt).not.toContain("Linked issue:");
|
|
44
|
+
expect(prompt).not.toContain("Issue URL:");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("includes common operations guidance", () => {
|
|
48
|
+
// The prompt should tell the agent what it can do with the Linear API
|
|
49
|
+
const prompt = buildLinearSystemPrompt(connection);
|
|
50
|
+
expect(prompt).toContain("add comments");
|
|
51
|
+
expect(prompt).toContain("transition issue status");
|
|
52
|
+
expect(prompt).toContain("read issue details");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns a multi-line string with newlines", () => {
|
|
56
|
+
// The prompt must be multi-line for readability in the system prompt
|
|
57
|
+
const prompt = buildLinearSystemPrompt(connection, issue);
|
|
58
|
+
const lines = prompt.split("\n");
|
|
59
|
+
expect(lines.length).toBeGreaterThan(3);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildLinearOAuthSystemPrompt", () => {
|
|
64
|
+
it("includes OAuth token guidance for app-scoped Linear access", () => {
|
|
65
|
+
const prompt = buildLinearOAuthSystemPrompt({ name: "Enrich" });
|
|
66
|
+
|
|
67
|
+
expect(prompt).toContain("LINEAR_OAUTH_ACCESS_TOKEN");
|
|
68
|
+
expect(prompt).toContain('Connected Linear OAuth app: "Enrich"');
|
|
69
|
+
expect(prompt).toContain("actor=app");
|
|
70
|
+
expect(prompt).toContain("https://api.linear.app/graphql");
|
|
71
|
+
expect(prompt).toContain("Authorization: Bearer $LINEAR_OAUTH_ACCESS_TOKEN");
|
|
72
|
+
expect(prompt).toContain("LINEAR_API_KEY");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ─── Linear System Prompt Builder ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Builds a system prompt snippet that tells Claude Code / Codex about the
|
|
4
|
+
// LINEAR_API_KEY environment variable and the linked Linear issue context.
|
|
5
|
+
// This is injected via the `initialize` control request's `appendSystemPrompt`
|
|
6
|
+
// field (Claude Code) or the `instructions` field in `thread/start` (Codex).
|
|
7
|
+
|
|
8
|
+
interface LinearConnectionContext {
|
|
9
|
+
workspaceName: string;
|
|
10
|
+
viewerName: string;
|
|
11
|
+
viewerEmail: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface LinearIssueContext {
|
|
15
|
+
identifier: string;
|
|
16
|
+
title: string;
|
|
17
|
+
stateName: string;
|
|
18
|
+
teamName: string;
|
|
19
|
+
url: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildLinearSystemPrompt(
|
|
23
|
+
connection: LinearConnectionContext,
|
|
24
|
+
issue?: LinearIssueContext,
|
|
25
|
+
): string {
|
|
26
|
+
const lines = [
|
|
27
|
+
"You have access to the Linear API via the LINEAR_API_KEY environment variable.",
|
|
28
|
+
`Connected workspace: "${connection.workspaceName}" (viewer: ${connection.viewerName}, ${connection.viewerEmail})`,
|
|
29
|
+
];
|
|
30
|
+
if (issue) {
|
|
31
|
+
lines.push(
|
|
32
|
+
`Linked issue: ${issue.identifier} — "${issue.title}" (status: ${issue.stateName}, team: ${issue.teamName})`,
|
|
33
|
+
);
|
|
34
|
+
lines.push(`Issue URL: ${issue.url}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(
|
|
38
|
+
"You can use this key to call the Linear GraphQL API at https://api.linear.app/graphql.",
|
|
39
|
+
);
|
|
40
|
+
lines.push(
|
|
41
|
+
"Use the Authorization header: `Authorization: Bearer $LINEAR_API_KEY`",
|
|
42
|
+
);
|
|
43
|
+
lines.push(
|
|
44
|
+
"Common operations: add comments, transition issue status, read issue details, update issue fields.",
|
|
45
|
+
);
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildLinearOAuthSystemPrompt(connection: { name: string }): string {
|
|
50
|
+
const lines = [
|
|
51
|
+
"You have access to the Linear GraphQL API via the LINEAR_OAUTH_ACCESS_TOKEN environment variable.",
|
|
52
|
+
`Connected Linear OAuth app: "${connection.name}"`,
|
|
53
|
+
"This token was authorized with `actor=app`, so Linear mutations run as the installed app rather than as the installing user.",
|
|
54
|
+
"",
|
|
55
|
+
"Call the Linear GraphQL API at https://api.linear.app/graphql.",
|
|
56
|
+
"Use the Authorization header: `Authorization: Bearer $LINEAR_OAUTH_ACCESS_TOKEN`",
|
|
57
|
+
"For compatibility with existing tooling, the same token is also available as `LINEAR_API_KEY`.",
|
|
58
|
+
"Common operations: read issue details, add comments, transition issue status, update issue fields, and create follow-up issues.",
|
|
59
|
+
];
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// Redirect COMPANION_HOME to a temp directory so tests don't touch real config
|
|
7
|
+
const TEST_HOME = join(tmpdir(), `linear-staging-test-${Date.now()}`);
|
|
8
|
+
process.env.COMPANION_HOME = TEST_HOME;
|
|
9
|
+
|
|
10
|
+
// Import after setting env var so the module picks up the test directory
|
|
11
|
+
const staging = await import("./linear-staging.js");
|
|
12
|
+
|
|
13
|
+
describe("linear-staging", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mkdirSync(TEST_HOME, { recursive: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
try {
|
|
20
|
+
rmSync(TEST_HOME, { recursive: true, force: true });
|
|
21
|
+
} catch { /* ok */ }
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("createSlot", () => {
|
|
25
|
+
it("creates a slot and returns a hex ID", () => {
|
|
26
|
+
const id = staging.createSlot({
|
|
27
|
+
clientId: "cid",
|
|
28
|
+
clientSecret: "csecret",
|
|
29
|
+
webhookSecret: "wsecret",
|
|
30
|
+
});
|
|
31
|
+
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("creates the staging directory and JSON file", () => {
|
|
35
|
+
const id = staging.createSlot({
|
|
36
|
+
clientId: "cid",
|
|
37
|
+
clientSecret: "csecret",
|
|
38
|
+
webhookSecret: "wsecret",
|
|
39
|
+
});
|
|
40
|
+
const files = readdirSync(join(TEST_HOME, "staging"));
|
|
41
|
+
expect(files).toContain(`${id}.json`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("getSlot", () => {
|
|
46
|
+
it("returns the slot with matching credentials", () => {
|
|
47
|
+
const id = staging.createSlot({
|
|
48
|
+
clientId: "my-client",
|
|
49
|
+
clientSecret: "my-secret",
|
|
50
|
+
webhookSecret: "my-webhook",
|
|
51
|
+
});
|
|
52
|
+
const slot = staging.getSlot(id);
|
|
53
|
+
expect(slot).not.toBeNull();
|
|
54
|
+
expect(slot!.clientId).toBe("my-client");
|
|
55
|
+
expect(slot!.clientSecret).toBe("my-secret");
|
|
56
|
+
expect(slot!.webhookSecret).toBe("my-webhook");
|
|
57
|
+
expect(slot!.accessToken).toBe("");
|
|
58
|
+
expect(slot!.refreshToken).toBe("");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns null for a non-existent slot", () => {
|
|
62
|
+
expect(staging.getSlot("nonexistent")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("updateSlotTokens", () => {
|
|
67
|
+
it("updates the access and refresh tokens", () => {
|
|
68
|
+
const id = staging.createSlot({
|
|
69
|
+
clientId: "cid",
|
|
70
|
+
clientSecret: "csecret",
|
|
71
|
+
webhookSecret: "wsecret",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const updated = staging.updateSlotTokens(id, {
|
|
75
|
+
accessToken: "at_123",
|
|
76
|
+
refreshToken: "rt_456",
|
|
77
|
+
});
|
|
78
|
+
expect(updated).toBe(true);
|
|
79
|
+
|
|
80
|
+
const slot = staging.getSlot(id);
|
|
81
|
+
expect(slot!.accessToken).toBe("at_123");
|
|
82
|
+
expect(slot!.refreshToken).toBe("rt_456");
|
|
83
|
+
// Original credentials are preserved
|
|
84
|
+
expect(slot!.clientId).toBe("cid");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns false for a non-existent slot", () => {
|
|
88
|
+
expect(staging.updateSlotTokens("nope", { accessToken: "a", refreshToken: "r" })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("consumeSlot", () => {
|
|
93
|
+
it("returns the slot and deletes it", () => {
|
|
94
|
+
const id = staging.createSlot({
|
|
95
|
+
clientId: "cid",
|
|
96
|
+
clientSecret: "csecret",
|
|
97
|
+
webhookSecret: "wsecret",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const slot = staging.consumeSlot(id);
|
|
101
|
+
expect(slot).not.toBeNull();
|
|
102
|
+
expect(slot!.clientId).toBe("cid");
|
|
103
|
+
|
|
104
|
+
// Slot is gone after consuming
|
|
105
|
+
expect(staging.getSlot(id)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null for a non-existent slot", () => {
|
|
109
|
+
expect(staging.consumeSlot("nonexistent")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("deleteSlot", () => {
|
|
114
|
+
it("deletes an existing slot", () => {
|
|
115
|
+
const id = staging.createSlot({
|
|
116
|
+
clientId: "cid",
|
|
117
|
+
clientSecret: "csecret",
|
|
118
|
+
webhookSecret: "wsecret",
|
|
119
|
+
});
|
|
120
|
+
expect(staging.deleteSlot(id)).toBe(true);
|
|
121
|
+
expect(staging.getSlot(id)).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns false for a non-existent slot", () => {
|
|
125
|
+
expect(staging.deleteSlot("nonexistent")).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("multiple slots", () => {
|
|
130
|
+
it("supports multiple concurrent staging slots", () => {
|
|
131
|
+
// Validates that multiple wizards can run in parallel
|
|
132
|
+
const id1 = staging.createSlot({
|
|
133
|
+
clientId: "client-A",
|
|
134
|
+
clientSecret: "secret-A",
|
|
135
|
+
webhookSecret: "webhook-A",
|
|
136
|
+
});
|
|
137
|
+
const id2 = staging.createSlot({
|
|
138
|
+
clientId: "client-B",
|
|
139
|
+
clientSecret: "secret-B",
|
|
140
|
+
webhookSecret: "webhook-B",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(id1).not.toBe(id2);
|
|
144
|
+
|
|
145
|
+
const slot1 = staging.getSlot(id1);
|
|
146
|
+
const slot2 = staging.getSlot(id2);
|
|
147
|
+
expect(slot1!.clientId).toBe("client-A");
|
|
148
|
+
expect(slot2!.clientId).toBe("client-B");
|
|
149
|
+
|
|
150
|
+
// Consuming one doesn't affect the other
|
|
151
|
+
staging.consumeSlot(id1);
|
|
152
|
+
expect(staging.getSlot(id1)).toBeNull();
|
|
153
|
+
expect(staging.getSlot(id2)).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("TTL / expiry", () => {
|
|
158
|
+
// Slots have a 30-minute TTL. After that window, getSlot should treat
|
|
159
|
+
// them as expired and return null (also cleaning up the file).
|
|
160
|
+
it("getSlot returns null for a slot whose createdAt is older than 30 minutes", () => {
|
|
161
|
+
const id = staging.createSlot({
|
|
162
|
+
clientId: "cid",
|
|
163
|
+
clientSecret: "csecret",
|
|
164
|
+
webhookSecret: "wsecret",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Backdate createdAt to 31 minutes ago so it exceeds the 30-min TTL
|
|
168
|
+
const filePath = join(TEST_HOME, "staging", `${id}.json`);
|
|
169
|
+
const slot = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
170
|
+
slot.createdAt = Date.now() - 31 * 60 * 1000;
|
|
171
|
+
writeFileSync(filePath, JSON.stringify(slot, null, 2));
|
|
172
|
+
|
|
173
|
+
// The slot should now be treated as expired
|
|
174
|
+
expect(staging.getSlot(id)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("pruneExpired", () => {
|
|
179
|
+
// pruneExpired should remove all slot files whose createdAt exceeds
|
|
180
|
+
// the 30-minute TTL, leaving fresh slots untouched.
|
|
181
|
+
it("removes stale files from the staging directory", () => {
|
|
182
|
+
// Create two slots: one will be backdated (expired), one stays fresh
|
|
183
|
+
const expiredId = staging.createSlot({
|
|
184
|
+
clientId: "old-client",
|
|
185
|
+
clientSecret: "old-secret",
|
|
186
|
+
webhookSecret: "old-webhook",
|
|
187
|
+
});
|
|
188
|
+
const freshId = staging.createSlot({
|
|
189
|
+
clientId: "new-client",
|
|
190
|
+
clientSecret: "new-secret",
|
|
191
|
+
webhookSecret: "new-webhook",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Backdate the first slot to 31 minutes ago
|
|
195
|
+
const expiredPath = join(TEST_HOME, "staging", `${expiredId}.json`);
|
|
196
|
+
const expiredSlot = JSON.parse(readFileSync(expiredPath, "utf-8"));
|
|
197
|
+
expiredSlot.createdAt = Date.now() - 31 * 60 * 1000;
|
|
198
|
+
writeFileSync(expiredPath, JSON.stringify(expiredSlot, null, 2));
|
|
199
|
+
|
|
200
|
+
// Run pruneExpired explicitly
|
|
201
|
+
staging.pruneExpired();
|
|
202
|
+
|
|
203
|
+
// The expired slot file should be gone
|
|
204
|
+
const remaining = readdirSync(join(TEST_HOME, "staging"));
|
|
205
|
+
expect(remaining).not.toContain(`${expiredId}.json`);
|
|
206
|
+
|
|
207
|
+
// The fresh slot should still be present
|
|
208
|
+
expect(remaining).toContain(`${freshId}.json`);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("updateSlotTokens on expired slot", () => {
|
|
213
|
+
// updateSlotTokens delegates to getSlot internally, so if the slot is
|
|
214
|
+
// expired it should return false and not persist any token update.
|
|
215
|
+
it("returns false when the slot has expired", () => {
|
|
216
|
+
const id = staging.createSlot({
|
|
217
|
+
clientId: "cid",
|
|
218
|
+
clientSecret: "csecret",
|
|
219
|
+
webhookSecret: "wsecret",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Backdate createdAt to 31 minutes ago
|
|
223
|
+
const filePath = join(TEST_HOME, "staging", `${id}.json`);
|
|
224
|
+
const slot = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
225
|
+
slot.createdAt = Date.now() - 31 * 60 * 1000;
|
|
226
|
+
writeFileSync(filePath, JSON.stringify(slot, null, 2));
|
|
227
|
+
|
|
228
|
+
// Attempting to update tokens on an expired slot should fail
|
|
229
|
+
const result = staging.updateSlotTokens(id, {
|
|
230
|
+
accessToken: "at_new",
|
|
231
|
+
refreshToken: "rt_new",
|
|
232
|
+
});
|
|
233
|
+
expect(result).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("path traversal protection", () => {
|
|
238
|
+
// The internal slotPath helper validates IDs against /^[0-9a-f]{32}$/.
|
|
239
|
+
// Any ID that doesn't match (e.g. containing "../") is rejected.
|
|
240
|
+
// Public functions that wrap slotPath in try/catch safely return
|
|
241
|
+
// null/false instead of throwing, but the key invariant is that
|
|
242
|
+
// no file outside the staging directory is ever accessed.
|
|
243
|
+
|
|
244
|
+
const maliciousIds = [
|
|
245
|
+
"../settings",
|
|
246
|
+
"../../etc/passwd",
|
|
247
|
+
"../staging/legit",
|
|
248
|
+
"a".repeat(31) + "/", // wrong length + slash
|
|
249
|
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0", // uppercase hex — regex requires lowercase
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
it("getSlot safely rejects malicious IDs (returns null)", () => {
|
|
253
|
+
// getSlot wraps slotPath in a try/catch, so the invalid-ID error
|
|
254
|
+
// is caught and the function returns null — no file access occurs.
|
|
255
|
+
for (const id of maliciousIds) {
|
|
256
|
+
expect(staging.getSlot(id)).toBeNull();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("deleteSlot safely rejects malicious IDs (returns false)", () => {
|
|
261
|
+
// deleteSlot wraps unlinkSync(slotPath(id)) in a try/catch,
|
|
262
|
+
// so the invalid-ID error causes it to return false.
|
|
263
|
+
for (const id of maliciousIds) {
|
|
264
|
+
expect(staging.deleteSlot(id)).toBe(false);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("consumeSlot safely rejects malicious IDs (returns null)", () => {
|
|
269
|
+
// consumeSlot delegates to getSlot first, which returns null
|
|
270
|
+
// for invalid IDs, so consumeSlot returns null immediately.
|
|
271
|
+
for (const id of maliciousIds) {
|
|
272
|
+
expect(staging.consumeSlot(id)).toBeNull();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|