@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,337 @@
|
|
|
1
|
+
// Tests for the Linear OAuth credential migration module.
|
|
2
|
+
// Verifies the one-time migration of global Linear OAuth credentials
|
|
3
|
+
// from settings.json to the first eligible agent (has linear trigger
|
|
4
|
+
// enabled but no per-agent oauthClientId yet).
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
import type { AgentConfig } from "./agent-types.js";
|
|
9
|
+
|
|
10
|
+
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
// Mock the agent store: listAgents and updateAgent
|
|
13
|
+
vi.mock("./agent-store.js", () => ({
|
|
14
|
+
listAgents: vi.fn(() => []),
|
|
15
|
+
updateAgent: vi.fn(() => null),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock the settings manager: getSettings and updateSettings
|
|
19
|
+
vi.mock("./settings-manager.js", () => ({
|
|
20
|
+
getSettings: vi.fn(() => ({
|
|
21
|
+
linearOAuthClientId: "",
|
|
22
|
+
linearOAuthClientSecret: "",
|
|
23
|
+
linearOAuthWebhookSecret: "",
|
|
24
|
+
linearOAuthAccessToken: "",
|
|
25
|
+
linearOAuthRefreshToken: "",
|
|
26
|
+
})),
|
|
27
|
+
updateSettings: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { migrateLinearCredentialsToAgents } from "./linear-credential-migration.js";
|
|
31
|
+
import * as agentStore from "./agent-store.js";
|
|
32
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
33
|
+
|
|
34
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Build a minimal AgentConfig for testing with optional overrides. */
|
|
37
|
+
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|
38
|
+
return {
|
|
39
|
+
id: "test-agent",
|
|
40
|
+
version: 1,
|
|
41
|
+
name: "Test Agent",
|
|
42
|
+
description: "",
|
|
43
|
+
backendType: "claude",
|
|
44
|
+
model: "claude-sonnet-4-6",
|
|
45
|
+
permissionMode: "default",
|
|
46
|
+
cwd: "/tmp",
|
|
47
|
+
prompt: "do stuff",
|
|
48
|
+
enabled: true,
|
|
49
|
+
createdAt: Date.now(),
|
|
50
|
+
updatedAt: Date.now(),
|
|
51
|
+
totalRuns: 0,
|
|
52
|
+
consecutiveFailures: 0,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("migrateLinearCredentialsToAgents", () => {
|
|
64
|
+
// When no global OAuth client ID exists in settings, the function should
|
|
65
|
+
// return early without even querying for agents.
|
|
66
|
+
it("does nothing when no global OAuth credentials exist", () => {
|
|
67
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
68
|
+
linearOAuthClientId: "",
|
|
69
|
+
linearOAuthClientSecret: "",
|
|
70
|
+
linearOAuthWebhookSecret: "",
|
|
71
|
+
linearOAuthAccessToken: "",
|
|
72
|
+
linearOAuthRefreshToken: "",
|
|
73
|
+
} as ReturnType<typeof getSettings>);
|
|
74
|
+
|
|
75
|
+
migrateLinearCredentialsToAgents();
|
|
76
|
+
|
|
77
|
+
// Should not attempt to list agents when there are no credentials to migrate
|
|
78
|
+
expect(agentStore.listAgents).not.toHaveBeenCalled();
|
|
79
|
+
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
|
80
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// When global credentials exist but no agent has linear enabled without
|
|
84
|
+
// existing oauthClientId, the function should log a message and return
|
|
85
|
+
// without modifying anything.
|
|
86
|
+
it("does nothing when no eligible agent is found", () => {
|
|
87
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
88
|
+
linearOAuthClientId: "client-id-123",
|
|
89
|
+
linearOAuthClientSecret: "secret-456",
|
|
90
|
+
linearOAuthWebhookSecret: "webhook-789",
|
|
91
|
+
linearOAuthAccessToken: "access-token",
|
|
92
|
+
linearOAuthRefreshToken: "refresh-token",
|
|
93
|
+
} as ReturnType<typeof getSettings>);
|
|
94
|
+
|
|
95
|
+
// Agent has linear enabled but already has its own oauthClientId
|
|
96
|
+
const agentWithCreds = makeAgent({
|
|
97
|
+
id: "already-configured",
|
|
98
|
+
name: "Already Configured",
|
|
99
|
+
triggers: {
|
|
100
|
+
linear: {
|
|
101
|
+
enabled: true,
|
|
102
|
+
oauthClientId: "existing-client-id",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([agentWithCreds]);
|
|
108
|
+
|
|
109
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
110
|
+
|
|
111
|
+
migrateLinearCredentialsToAgents();
|
|
112
|
+
|
|
113
|
+
// Should have listed agents to search for eligible ones
|
|
114
|
+
expect(agentStore.listAgents).toHaveBeenCalled();
|
|
115
|
+
// But should not have updated any agent or cleared settings
|
|
116
|
+
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
|
117
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
118
|
+
// Should log a staging message
|
|
119
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
120
|
+
expect.stringContaining("no Linear agent found to migrate to"),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
consoleSpy.mockRestore();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Happy path: global credentials exist and there is exactly one eligible
|
|
127
|
+
// agent (linear enabled, no oauthClientId). The function should copy all
|
|
128
|
+
// credentials to the agent and then clear the global settings.
|
|
129
|
+
it("migrates credentials to the first eligible agent", () => {
|
|
130
|
+
const globalCreds = {
|
|
131
|
+
linearOAuthClientId: "client-id-123",
|
|
132
|
+
linearOAuthClientSecret: "secret-456",
|
|
133
|
+
linearOAuthWebhookSecret: "webhook-789",
|
|
134
|
+
linearOAuthAccessToken: "access-token",
|
|
135
|
+
linearOAuthRefreshToken: "refresh-token",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
vi.mocked(getSettings).mockReturnValue(
|
|
139
|
+
globalCreds as ReturnType<typeof getSettings>,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const eligibleAgent = makeAgent({
|
|
143
|
+
id: "linear-agent",
|
|
144
|
+
name: "My Linear Agent",
|
|
145
|
+
triggers: {
|
|
146
|
+
linear: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
// No oauthClientId — eligible for migration
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([eligibleAgent]);
|
|
154
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(eligibleAgent);
|
|
155
|
+
|
|
156
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
157
|
+
|
|
158
|
+
migrateLinearCredentialsToAgents();
|
|
159
|
+
|
|
160
|
+
// Should copy all global credentials to the agent's linear trigger config
|
|
161
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith("linear-agent", {
|
|
162
|
+
triggers: {
|
|
163
|
+
linear: {
|
|
164
|
+
enabled: true,
|
|
165
|
+
oauthClientId: "client-id-123",
|
|
166
|
+
oauthClientSecret: "secret-456",
|
|
167
|
+
webhookSecret: "webhook-789",
|
|
168
|
+
accessToken: "access-token",
|
|
169
|
+
refreshToken: "refresh-token",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Should clear global credentials after successful migration
|
|
175
|
+
expect(updateSettings).toHaveBeenCalledWith({
|
|
176
|
+
linearOAuthClientId: "",
|
|
177
|
+
linearOAuthClientSecret: "",
|
|
178
|
+
linearOAuthWebhookSecret: "",
|
|
179
|
+
linearOAuthAccessToken: "",
|
|
180
|
+
linearOAuthRefreshToken: "",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Should log success with agent name and ID
|
|
184
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
185
|
+
expect.stringContaining('Migrated global OAuth credentials to agent "My Linear Agent"'),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
consoleSpy.mockRestore();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// When multiple agents have linear enabled, but the first one already has
|
|
192
|
+
// its own oauthClientId, the migration should skip it and migrate to the
|
|
193
|
+
// second agent that lacks credentials.
|
|
194
|
+
it("skips agents that already have oauthClientId and migrates to the next eligible one", () => {
|
|
195
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
196
|
+
linearOAuthClientId: "global-client",
|
|
197
|
+
linearOAuthClientSecret: "global-secret",
|
|
198
|
+
linearOAuthWebhookSecret: "global-webhook",
|
|
199
|
+
linearOAuthAccessToken: "global-access",
|
|
200
|
+
linearOAuthRefreshToken: "global-refresh",
|
|
201
|
+
} as ReturnType<typeof getSettings>);
|
|
202
|
+
|
|
203
|
+
const agentWithCreds = makeAgent({
|
|
204
|
+
id: "agent-with-creds",
|
|
205
|
+
name: "Agent With Creds",
|
|
206
|
+
triggers: {
|
|
207
|
+
linear: {
|
|
208
|
+
enabled: true,
|
|
209
|
+
oauthClientId: "already-has-one",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const agentWithoutCreds = makeAgent({
|
|
215
|
+
id: "agent-without-creds",
|
|
216
|
+
name: "Agent Without Creds",
|
|
217
|
+
triggers: {
|
|
218
|
+
linear: {
|
|
219
|
+
enabled: true,
|
|
220
|
+
// No oauthClientId — this one should receive the migration
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([
|
|
226
|
+
agentWithCreds,
|
|
227
|
+
agentWithoutCreds,
|
|
228
|
+
]);
|
|
229
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(agentWithoutCreds);
|
|
230
|
+
|
|
231
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
232
|
+
|
|
233
|
+
migrateLinearCredentialsToAgents();
|
|
234
|
+
|
|
235
|
+
// Should migrate to the second agent (the one without existing credentials)
|
|
236
|
+
expect(agentStore.updateAgent).toHaveBeenCalledWith(
|
|
237
|
+
"agent-without-creds",
|
|
238
|
+
expect.objectContaining({
|
|
239
|
+
triggers: expect.objectContaining({
|
|
240
|
+
linear: expect.objectContaining({
|
|
241
|
+
oauthClientId: "global-client",
|
|
242
|
+
}),
|
|
243
|
+
}),
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Should clear global settings after migration
|
|
248
|
+
expect(updateSettings).toHaveBeenCalled();
|
|
249
|
+
|
|
250
|
+
consoleSpy.mockRestore();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// An agent exists but its linear trigger is not enabled (enabled: false).
|
|
254
|
+
// It should not be considered eligible for migration.
|
|
255
|
+
it("skips agents without linear trigger enabled", () => {
|
|
256
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
257
|
+
linearOAuthClientId: "client-id",
|
|
258
|
+
linearOAuthClientSecret: "secret",
|
|
259
|
+
linearOAuthWebhookSecret: "webhook",
|
|
260
|
+
linearOAuthAccessToken: "access",
|
|
261
|
+
linearOAuthRefreshToken: "refresh",
|
|
262
|
+
} as ReturnType<typeof getSettings>);
|
|
263
|
+
|
|
264
|
+
const disabledLinearAgent = makeAgent({
|
|
265
|
+
id: "disabled-linear",
|
|
266
|
+
name: "Disabled Linear Agent",
|
|
267
|
+
triggers: {
|
|
268
|
+
linear: {
|
|
269
|
+
enabled: false,
|
|
270
|
+
// No oauthClientId, but linear is disabled so it shouldn't qualify
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Also test an agent with no triggers at all
|
|
276
|
+
const noTriggersAgent = makeAgent({
|
|
277
|
+
id: "no-triggers",
|
|
278
|
+
name: "No Triggers Agent",
|
|
279
|
+
// No triggers property
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([
|
|
283
|
+
disabledLinearAgent,
|
|
284
|
+
noTriggersAgent,
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
288
|
+
|
|
289
|
+
migrateLinearCredentialsToAgents();
|
|
290
|
+
|
|
291
|
+
// Neither agent should receive credentials
|
|
292
|
+
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
|
293
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
294
|
+
|
|
295
|
+
// Should log the "no agent found" message
|
|
296
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
297
|
+
expect.stringContaining("no Linear agent found to migrate to"),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
consoleSpy.mockRestore();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// If updateAgent returns null (store failure), the global credentials
|
|
304
|
+
// must NOT be cleared — otherwise the user's credentials are silently lost.
|
|
305
|
+
it("does NOT clear global credentials when updateAgent fails during migration", () => {
|
|
306
|
+
vi.mocked(getSettings).mockReturnValue({
|
|
307
|
+
linearOAuthClientId: "client-id",
|
|
308
|
+
linearOAuthClientSecret: "secret",
|
|
309
|
+
linearOAuthWebhookSecret: "webhook",
|
|
310
|
+
linearOAuthAccessToken: "access",
|
|
311
|
+
linearOAuthRefreshToken: "refresh",
|
|
312
|
+
} as ReturnType<typeof getSettings>);
|
|
313
|
+
|
|
314
|
+
const eligible = makeAgent({
|
|
315
|
+
id: "linear-agent",
|
|
316
|
+
name: "Linear Agent",
|
|
317
|
+
triggers: { linear: { enabled: true } },
|
|
318
|
+
});
|
|
319
|
+
vi.mocked(agentStore.listAgents).mockReturnValue([eligible]);
|
|
320
|
+
// Simulate a store failure
|
|
321
|
+
vi.mocked(agentStore.updateAgent).mockReturnValue(null);
|
|
322
|
+
|
|
323
|
+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
324
|
+
|
|
325
|
+
migrateLinearCredentialsToAgents();
|
|
326
|
+
|
|
327
|
+
expect(agentStore.updateAgent).toHaveBeenCalled();
|
|
328
|
+
// Credentials must NOT be cleared if the agent write failed
|
|
329
|
+
expect(updateSettings).not.toHaveBeenCalled();
|
|
330
|
+
// Should log an error about the failure
|
|
331
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
332
|
+
expect.stringContaining("Failed to write credentials to agent"),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
consoleErrorSpy.mockRestore();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Linear OAuth Credential Migration
|
|
2
|
+
// One-time migration: copies global Linear OAuth credentials from settings.json
|
|
3
|
+
// to the first Linear agent that doesn't have per-agent credentials.
|
|
4
|
+
// This runs on server startup to handle the transition from global to per-agent storage.
|
|
5
|
+
|
|
6
|
+
import * as agentStore from "./agent-store.js";
|
|
7
|
+
import { getSettings, updateSettings } from "./settings-manager.js";
|
|
8
|
+
|
|
9
|
+
/** Migrate global Linear OAuth credentials to the first eligible agent.
|
|
10
|
+
* This is a one-time operation: once credentials are moved, global fields are cleared. */
|
|
11
|
+
export function migrateLinearCredentialsToAgents(): void {
|
|
12
|
+
const settings = getSettings();
|
|
13
|
+
|
|
14
|
+
// Nothing to migrate if no global OAuth client ID
|
|
15
|
+
if (!settings.linearOAuthClientId.trim()) return;
|
|
16
|
+
|
|
17
|
+
const agents = agentStore.listAgents();
|
|
18
|
+
const linearAgent = agents.find(
|
|
19
|
+
(a) => a.triggers?.linear?.enabled && !a.triggers.linear.oauthClientId
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!linearAgent) {
|
|
23
|
+
console.log(
|
|
24
|
+
"[linear-migration] Global OAuth credentials exist but no Linear agent found to migrate to. Credentials will remain in global settings as staging."
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Copy credentials to the agent
|
|
30
|
+
const triggers = linearAgent.triggers!;
|
|
31
|
+
const updated = agentStore.updateAgent(linearAgent.id, {
|
|
32
|
+
triggers: {
|
|
33
|
+
...triggers,
|
|
34
|
+
linear: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
...triggers.linear,
|
|
37
|
+
oauthClientId: settings.linearOAuthClientId,
|
|
38
|
+
oauthClientSecret: settings.linearOAuthClientSecret,
|
|
39
|
+
webhookSecret: settings.linearOAuthWebhookSecret,
|
|
40
|
+
accessToken: settings.linearOAuthAccessToken,
|
|
41
|
+
refreshToken: settings.linearOAuthRefreshToken,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Only clear global credentials after a confirmed successful write
|
|
47
|
+
if (!updated) {
|
|
48
|
+
console.error("[linear-migration] Failed to write credentials to agent — global credentials preserved");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
updateSettings({
|
|
53
|
+
linearOAuthClientId: "",
|
|
54
|
+
linearOAuthClientSecret: "",
|
|
55
|
+
linearOAuthWebhookSecret: "",
|
|
56
|
+
linearOAuthAccessToken: "",
|
|
57
|
+
linearOAuthRefreshToken: "",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log(
|
|
61
|
+
`[linear-migration] Migrated global OAuth credentials to agent "${linearAgent.name}" (${linearAgent.id})`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the migrateFromAgents() auto-migration in linear-oauth-connections.ts.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - Agents with inline OAuth credentials get migrated to standalone connections
|
|
6
|
+
* - Global settings with OAuth credentials get migrated
|
|
7
|
+
* - Deduplication by oauthClientId (multiple agents sharing the same app)
|
|
8
|
+
* - Agents get updated with oauthConnectionId after migration
|
|
9
|
+
* - No migration when connections already exist
|
|
10
|
+
* - Agents without oauthClientId are skipped
|
|
11
|
+
* - Status correctly derived from accessToken presence
|
|
12
|
+
* - Migrated connections persist to disk
|
|
13
|
+
*
|
|
14
|
+
* Uses the exported `migrateFromAgents(deps)` with injected dependencies
|
|
15
|
+
* instead of relying on `require()` interception.
|
|
16
|
+
*/
|
|
17
|
+
import { vi, describe, it, expect, beforeEach, afterAll } from "vitest";
|
|
18
|
+
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
listOAuthConnections,
|
|
24
|
+
createOAuthConnection,
|
|
25
|
+
migrateFromAgents,
|
|
26
|
+
_resetForTest,
|
|
27
|
+
} from "./linear-oauth-connections.js";
|
|
28
|
+
|
|
29
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const TEST_DIR = join(tmpdir(), `companion-oauth-migration-test-${Date.now()}`);
|
|
32
|
+
const TEST_FILE = join(TEST_DIR, "linear-oauth-connections.json");
|
|
33
|
+
|
|
34
|
+
const mockUpdateAgent = vi.fn();
|
|
35
|
+
|
|
36
|
+
function makeDeps(
|
|
37
|
+
agents: Array<Record<string, unknown>> = [],
|
|
38
|
+
settings: Record<string, string> = {},
|
|
39
|
+
) {
|
|
40
|
+
return {
|
|
41
|
+
listAgents: () => agents as Array<{ id: string; name: string; triggers?: { linear?: Record<string, unknown> } }>,
|
|
42
|
+
updateAgent: mockUpdateAgent as (id: string, patch: Record<string, unknown>) => void,
|
|
43
|
+
getSettings: () => ({
|
|
44
|
+
linearOAuthClientId: "",
|
|
45
|
+
linearOAuthClientSecret: "",
|
|
46
|
+
linearOAuthWebhookSecret: "",
|
|
47
|
+
linearOAuthAccessToken: "",
|
|
48
|
+
linearOAuthRefreshToken: "",
|
|
49
|
+
...settings,
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
_resetForTest(TEST_FILE);
|
|
57
|
+
if (existsSync(TEST_DIR)) {
|
|
58
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
if (existsSync(TEST_DIR)) {
|
|
65
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Tests
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
describe("migrateFromAgents", () => {
|
|
74
|
+
it("migrates agents with inline OAuth credentials to standalone connections", () => {
|
|
75
|
+
const deps = makeDeps([
|
|
76
|
+
{
|
|
77
|
+
id: "agent-1",
|
|
78
|
+
name: "Linear Bot",
|
|
79
|
+
triggers: {
|
|
80
|
+
linear: {
|
|
81
|
+
enabled: true,
|
|
82
|
+
oauthClientId: "inline-cid",
|
|
83
|
+
oauthClientSecret: "inline-csec",
|
|
84
|
+
webhookSecret: "inline-wsec",
|
|
85
|
+
accessToken: "inline-tok",
|
|
86
|
+
refreshToken: "inline-ref",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
migrateFromAgents(deps);
|
|
93
|
+
const conns = listOAuthConnections();
|
|
94
|
+
|
|
95
|
+
expect(conns).toHaveLength(1);
|
|
96
|
+
expect(conns[0].oauthClientId).toBe("inline-cid");
|
|
97
|
+
expect(conns[0].oauthClientSecret).toBe("inline-csec");
|
|
98
|
+
expect(conns[0].webhookSecret).toBe("inline-wsec");
|
|
99
|
+
expect(conns[0].accessToken).toBe("inline-tok");
|
|
100
|
+
expect(conns[0].status).toBe("connected");
|
|
101
|
+
expect(conns[0].name).toBe("Linear Bot OAuth App");
|
|
102
|
+
|
|
103
|
+
// Agent should be updated with oauthConnectionId
|
|
104
|
+
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
|
105
|
+
"agent-1",
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
triggers: expect.objectContaining({
|
|
108
|
+
linear: expect.objectContaining({
|
|
109
|
+
oauthConnectionId: conns[0].id,
|
|
110
|
+
}),
|
|
111
|
+
}),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("deduplicates by oauthClientId when multiple agents share the same app", () => {
|
|
117
|
+
const deps = makeDeps([
|
|
118
|
+
{
|
|
119
|
+
id: "agent-1",
|
|
120
|
+
name: "Bot A",
|
|
121
|
+
triggers: {
|
|
122
|
+
linear: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
oauthClientId: "shared-cid",
|
|
125
|
+
oauthClientSecret: "csec",
|
|
126
|
+
webhookSecret: "wsec",
|
|
127
|
+
accessToken: "tok",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "agent-2",
|
|
133
|
+
name: "Bot B",
|
|
134
|
+
triggers: {
|
|
135
|
+
linear: {
|
|
136
|
+
enabled: true,
|
|
137
|
+
oauthClientId: "shared-cid", // same clientId
|
|
138
|
+
oauthClientSecret: "csec",
|
|
139
|
+
webhookSecret: "wsec",
|
|
140
|
+
accessToken: "tok",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
migrateFromAgents(deps);
|
|
147
|
+
const conns = listOAuthConnections();
|
|
148
|
+
|
|
149
|
+
// Should create only one connection (deduplication)
|
|
150
|
+
expect(conns).toHaveLength(1);
|
|
151
|
+
|
|
152
|
+
// Both agents should be updated with the same connection ID
|
|
153
|
+
expect(mockUpdateAgent).toHaveBeenCalledTimes(2);
|
|
154
|
+
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-1", expect.anything());
|
|
155
|
+
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-2", expect.anything());
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("migrates from global settings when no agent credentials exist", () => {
|
|
159
|
+
const deps = makeDeps([], {
|
|
160
|
+
linearOAuthClientId: "settings-cid",
|
|
161
|
+
linearOAuthClientSecret: "settings-csec",
|
|
162
|
+
linearOAuthWebhookSecret: "settings-wsec",
|
|
163
|
+
linearOAuthAccessToken: "settings-tok",
|
|
164
|
+
linearOAuthRefreshToken: "settings-ref",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
migrateFromAgents(deps);
|
|
168
|
+
const conns = listOAuthConnections();
|
|
169
|
+
|
|
170
|
+
expect(conns).toHaveLength(1);
|
|
171
|
+
expect(conns[0].name).toBe("Default OAuth App");
|
|
172
|
+
expect(conns[0].oauthClientId).toBe("settings-cid");
|
|
173
|
+
expect(conns[0].status).toBe("connected");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("skips migration when connections already exist", () => {
|
|
177
|
+
// Pre-create a connection
|
|
178
|
+
createOAuthConnection({
|
|
179
|
+
name: "Existing",
|
|
180
|
+
oauthClientId: "existing-cid",
|
|
181
|
+
oauthClientSecret: "csec",
|
|
182
|
+
webhookSecret: "wsec",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const deps = makeDeps([
|
|
186
|
+
{
|
|
187
|
+
id: "agent-1",
|
|
188
|
+
name: "Bot",
|
|
189
|
+
triggers: {
|
|
190
|
+
linear: {
|
|
191
|
+
enabled: true,
|
|
192
|
+
oauthClientId: "new-cid",
|
|
193
|
+
oauthClientSecret: "csec",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
migrateFromAgents(deps);
|
|
200
|
+
const conns = listOAuthConnections();
|
|
201
|
+
|
|
202
|
+
// Should NOT create additional connections
|
|
203
|
+
expect(conns).toHaveLength(1);
|
|
204
|
+
expect(conns[0].oauthClientId).toBe("existing-cid");
|
|
205
|
+
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("skips agents without oauthClientId", () => {
|
|
209
|
+
const deps = makeDeps([
|
|
210
|
+
{
|
|
211
|
+
id: "agent-1",
|
|
212
|
+
name: "No OAuth",
|
|
213
|
+
triggers: { linear: { enabled: true } },
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
migrateFromAgents(deps);
|
|
218
|
+
expect(listOAuthConnections()).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("sets status to disconnected when agent has no accessToken", () => {
|
|
222
|
+
const deps = makeDeps([
|
|
223
|
+
{
|
|
224
|
+
id: "agent-1",
|
|
225
|
+
name: "Unconnected Bot",
|
|
226
|
+
triggers: {
|
|
227
|
+
linear: {
|
|
228
|
+
enabled: true,
|
|
229
|
+
oauthClientId: "cid",
|
|
230
|
+
oauthClientSecret: "csec",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
migrateFromAgents(deps);
|
|
237
|
+
const conns = listOAuthConnections();
|
|
238
|
+
|
|
239
|
+
expect(conns).toHaveLength(1);
|
|
240
|
+
expect(conns[0].status).toBe("disconnected");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("persists migrated connections to disk", () => {
|
|
244
|
+
const deps = makeDeps([
|
|
245
|
+
{
|
|
246
|
+
id: "agent-1",
|
|
247
|
+
name: "Persist Test",
|
|
248
|
+
triggers: {
|
|
249
|
+
linear: {
|
|
250
|
+
enabled: true,
|
|
251
|
+
oauthClientId: "persist-cid",
|
|
252
|
+
oauthClientSecret: "csec",
|
|
253
|
+
webhookSecret: "wsec",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
migrateFromAgents(deps);
|
|
260
|
+
expect(existsSync(TEST_FILE)).toBe(true);
|
|
261
|
+
|
|
262
|
+
// Reload from disk and verify
|
|
263
|
+
_resetForTest(TEST_FILE);
|
|
264
|
+
const conns = listOAuthConnections();
|
|
265
|
+
expect(conns).toHaveLength(1);
|
|
266
|
+
expect(conns[0].oauthClientId).toBe("persist-cid");
|
|
267
|
+
});
|
|
268
|
+
});
|