@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,479 @@
|
|
|
1
|
+
// ─── Linear Agent Interaction SDK Client ──────────────────────────────────────
|
|
2
|
+
// Handles OAuth token management, webhook signature verification, and GraphQL
|
|
3
|
+
// mutations for the Linear Agent Interaction SDK (agent sessions, activities).
|
|
4
|
+
//
|
|
5
|
+
// This module is parameterized — all functions accept credentials as arguments
|
|
6
|
+
// rather than reading from global settings. Callers are responsible for
|
|
7
|
+
// providing the correct LinearOAuthCredentials for the agent being operated on.
|
|
8
|
+
// Token refresh is handled transparently on 401.
|
|
9
|
+
|
|
10
|
+
import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
/** OAuth credentials for a specific Linear agent. */
|
|
13
|
+
export interface LinearOAuthCredentials {
|
|
14
|
+
clientId: string;
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
webhookSecret: string;
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── OAuth state management (CSRF protection) ───────────────────────────────
|
|
22
|
+
// Short-lived nonces for the OAuth authorization flow. Each nonce expires after 10 minutes.
|
|
23
|
+
const oauthStateNonces = new Map<string, number>();
|
|
24
|
+
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
25
|
+
|
|
26
|
+
/** Generate a random state nonce for OAuth CSRF protection.
|
|
27
|
+
* Optionally encodes a `stagingId` (for per-wizard staging slots), a
|
|
28
|
+
* `connectionId` (for OAuth connection management), and a `returnTo` path
|
|
29
|
+
* so the OAuth callback can redirect back to the originating page.
|
|
30
|
+
*
|
|
31
|
+
* State format: `{nonce}` or `{nonce}:{segments}` where segments are
|
|
32
|
+
* colon-separated. A segment starting with `sid=` is the staging ID;
|
|
33
|
+
* `cid=` is the connection ID; anything else is a returnTo path. */
|
|
34
|
+
export function generateOAuthState(options?: { stagingId?: string; connectionId?: string; returnTo?: string }): string {
|
|
35
|
+
// Prune expired nonces
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [nonce, expiresAt] of oauthStateNonces) {
|
|
38
|
+
if (expiresAt < now) oauthStateNonces.delete(nonce);
|
|
39
|
+
}
|
|
40
|
+
const nonce = randomBytes(24).toString("hex");
|
|
41
|
+
oauthStateNonces.set(nonce, now + OAUTH_STATE_TTL_MS);
|
|
42
|
+
|
|
43
|
+
const parts = [nonce];
|
|
44
|
+
if (options?.stagingId) parts.push(`sid=${options.stagingId}`);
|
|
45
|
+
if (options?.connectionId) parts.push(`cid=${options.connectionId}`);
|
|
46
|
+
if (options?.returnTo) parts.push(encodeURIComponent(options.returnTo));
|
|
47
|
+
return parts.join(":");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate and consume an OAuth state nonce.
|
|
51
|
+
* Returns validity, an optional `stagingId`, `connectionId`, and `returnTo` path. */
|
|
52
|
+
export function validateOAuthState(state: string | null | undefined): { valid: boolean; stagingId?: string; connectionId?: string; returnTo?: string } {
|
|
53
|
+
if (!state) return { valid: false };
|
|
54
|
+
|
|
55
|
+
const parts = state.split(":");
|
|
56
|
+
const nonce = parts[0];
|
|
57
|
+
|
|
58
|
+
let stagingId: string | undefined;
|
|
59
|
+
let connectionId: string | undefined;
|
|
60
|
+
let returnTo: string | undefined;
|
|
61
|
+
|
|
62
|
+
// Parse remaining segments after the nonce
|
|
63
|
+
for (let i = 1; i < parts.length; i++) {
|
|
64
|
+
const segment = parts[i];
|
|
65
|
+
if (segment.startsWith("sid=")) {
|
|
66
|
+
stagingId = segment.slice(4);
|
|
67
|
+
} else if (segment.startsWith("cid=")) {
|
|
68
|
+
connectionId = segment.slice(4);
|
|
69
|
+
} else if (!returnTo) {
|
|
70
|
+
// First non-sid/cid segment is returnTo (may need reassembly if returnTo itself contained encoded colons)
|
|
71
|
+
returnTo = decodeURIComponent(parts.slice(i).filter(s => !s.startsWith("sid=") && !s.startsWith("cid=")).join(":"));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const expiresAt = oauthStateNonces.get(nonce);
|
|
77
|
+
if (!expiresAt) return { valid: false };
|
|
78
|
+
oauthStateNonces.delete(nonce); // consume — single use
|
|
79
|
+
return Date.now() < expiresAt ? { valid: true, stagingId, connectionId, returnTo } : { valid: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export type AgentActivityType = "thought" | "action" | "elicitation" | "response" | "error";
|
|
85
|
+
|
|
86
|
+
export interface ThoughtContent {
|
|
87
|
+
type: "thought";
|
|
88
|
+
body: string;
|
|
89
|
+
ephemeral?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ActionContent {
|
|
93
|
+
type: "action";
|
|
94
|
+
action: string;
|
|
95
|
+
parameter?: string;
|
|
96
|
+
result?: string;
|
|
97
|
+
ephemeral?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ElicitationContent {
|
|
101
|
+
type: "elicitation";
|
|
102
|
+
body: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ResponseContent {
|
|
106
|
+
type: "response";
|
|
107
|
+
body: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ErrorContent {
|
|
111
|
+
type: "error";
|
|
112
|
+
body: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type AgentActivityContent =
|
|
116
|
+
| ThoughtContent
|
|
117
|
+
| ActionContent
|
|
118
|
+
| ElicitationContent
|
|
119
|
+
| ResponseContent
|
|
120
|
+
| ErrorContent;
|
|
121
|
+
|
|
122
|
+
export interface AgentPlanItem {
|
|
123
|
+
content: string;
|
|
124
|
+
status: "pending" | "inProgress" | "completed" | "canceled";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The agent session object nested inside the webhook payload. */
|
|
128
|
+
export interface AgentSessionData {
|
|
129
|
+
id: string;
|
|
130
|
+
status: string;
|
|
131
|
+
createdAt: string;
|
|
132
|
+
updatedAt: string;
|
|
133
|
+
creatorId?: string;
|
|
134
|
+
issueId?: string;
|
|
135
|
+
commentId?: string;
|
|
136
|
+
url?: string;
|
|
137
|
+
externalUrls?: Array<{ label: string; url: string }>;
|
|
138
|
+
summary?: string | null;
|
|
139
|
+
plan?: unknown;
|
|
140
|
+
context?: unknown[];
|
|
141
|
+
creator?: {
|
|
142
|
+
id: string;
|
|
143
|
+
name: string;
|
|
144
|
+
email?: string;
|
|
145
|
+
url?: string;
|
|
146
|
+
};
|
|
147
|
+
comment?: {
|
|
148
|
+
id: string;
|
|
149
|
+
body: string;
|
|
150
|
+
userId: string;
|
|
151
|
+
issueId: string;
|
|
152
|
+
};
|
|
153
|
+
issue?: {
|
|
154
|
+
id: string;
|
|
155
|
+
title: string;
|
|
156
|
+
identifier: string;
|
|
157
|
+
url: string;
|
|
158
|
+
description?: string;
|
|
159
|
+
teamId?: string;
|
|
160
|
+
team?: {
|
|
161
|
+
id: string;
|
|
162
|
+
key: string;
|
|
163
|
+
name: string;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface AgentSessionEventPayload {
|
|
169
|
+
action: "created" | "prompted";
|
|
170
|
+
type: "AgentSessionEvent";
|
|
171
|
+
createdAt?: string;
|
|
172
|
+
organizationId?: string;
|
|
173
|
+
oauthClientId?: string;
|
|
174
|
+
appUserId?: string;
|
|
175
|
+
/** The agent session — contains id, issue, comment, creator, etc. */
|
|
176
|
+
agentSession?: AgentSessionData;
|
|
177
|
+
/** Rich XML prompt context provided by Linear */
|
|
178
|
+
promptContext?: string;
|
|
179
|
+
/** Previous comments in the thread */
|
|
180
|
+
previousComments?: Array<{
|
|
181
|
+
id: string;
|
|
182
|
+
body: string;
|
|
183
|
+
userId: string;
|
|
184
|
+
issueId: string;
|
|
185
|
+
}>;
|
|
186
|
+
/** Agent guidance configured in Linear */
|
|
187
|
+
guidance?: string | null;
|
|
188
|
+
/** Present on "prompted" events — the user's follow-up activity */
|
|
189
|
+
agentActivity?: {
|
|
190
|
+
id?: string;
|
|
191
|
+
/** Nested content with type and body */
|
|
192
|
+
content?: {
|
|
193
|
+
type?: string;
|
|
194
|
+
body?: string;
|
|
195
|
+
};
|
|
196
|
+
/** Direct body (legacy/alternative format) */
|
|
197
|
+
body?: string;
|
|
198
|
+
sourceCommentId?: string;
|
|
199
|
+
userId?: string;
|
|
200
|
+
user?: {
|
|
201
|
+
id: string;
|
|
202
|
+
name: string;
|
|
203
|
+
email?: string;
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
webhookTimestamp?: number;
|
|
207
|
+
webhookId?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── GraphQL helper ───────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/** Guard against concurrent 401s triggering multiple simultaneous refresh requests.
|
|
213
|
+
* Keyed by clientId so each agent's refresh is coalesced independently. */
|
|
214
|
+
const refreshPromises = new Map<string, Promise<string | null>>();
|
|
215
|
+
|
|
216
|
+
/** Get a refreshed token, coalescing concurrent refresh requests into a single call per agent. */
|
|
217
|
+
async function getRefreshedToken(
|
|
218
|
+
creds: LinearOAuthCredentials,
|
|
219
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
220
|
+
): Promise<string | null> {
|
|
221
|
+
const key = creds.clientId;
|
|
222
|
+
if (!refreshPromises.has(key)) {
|
|
223
|
+
const promise = refreshAccessToken(creds, onTokensRefreshed).finally(() => {
|
|
224
|
+
refreshPromises.delete(key);
|
|
225
|
+
});
|
|
226
|
+
refreshPromises.set(key, promise);
|
|
227
|
+
}
|
|
228
|
+
return refreshPromises.get(key)!;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Execute a GraphQL query against the Linear API with automatic token refresh. */
|
|
232
|
+
export async function linearGraphQL<T = unknown>(
|
|
233
|
+
creds: LinearOAuthCredentials,
|
|
234
|
+
query: string,
|
|
235
|
+
variables?: Record<string, unknown>,
|
|
236
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
237
|
+
): Promise<{ data?: T; errors?: Array<{ message: string }> }> {
|
|
238
|
+
let token = creds.accessToken;
|
|
239
|
+
|
|
240
|
+
if (!token) {
|
|
241
|
+
throw new Error("Linear OAuth not configured — no access token");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let response = await fetchGraphQL(token, query, variables);
|
|
245
|
+
|
|
246
|
+
// Auto-refresh on 401 — coalesced to prevent concurrent refresh races
|
|
247
|
+
if (response.status === 401 && creds.refreshToken) {
|
|
248
|
+
const refreshed = await getRefreshedToken(creds, onTokensRefreshed);
|
|
249
|
+
if (refreshed) {
|
|
250
|
+
token = refreshed;
|
|
251
|
+
response = await fetchGraphQL(token, query, variables);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
const text = await response.text().catch(() => "");
|
|
257
|
+
throw new Error(`Linear API error ${response.status}: ${text.slice(0, 200)}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return response.json() as Promise<{ data?: T; errors?: Array<{ message: string }> }>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function fetchGraphQL(
|
|
264
|
+
token: string,
|
|
265
|
+
query: string,
|
|
266
|
+
variables?: Record<string, unknown>,
|
|
267
|
+
): Promise<Response> {
|
|
268
|
+
return fetch("https://api.linear.app/graphql", {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
Authorization: `Bearer ${token}`,
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({ query, variables }),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Token management ─────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** Refresh the OAuth access token using the refresh token. Returns the new token or null. */
|
|
281
|
+
export async function refreshAccessToken(
|
|
282
|
+
creds: LinearOAuthCredentials,
|
|
283
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
284
|
+
): Promise<string | null> {
|
|
285
|
+
const { clientId, clientSecret, refreshToken } = creds;
|
|
286
|
+
|
|
287
|
+
if (!clientId || !clientSecret || !refreshToken) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const response = await fetch("https://api.linear.app/oauth/token", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
295
|
+
body: new URLSearchParams({
|
|
296
|
+
grant_type: "refresh_token",
|
|
297
|
+
refresh_token: refreshToken,
|
|
298
|
+
client_id: clientId,
|
|
299
|
+
client_secret: clientSecret,
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
console.error("[linear-agent] Token refresh failed:", response.status);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const data = await response.json() as {
|
|
309
|
+
access_token: string;
|
|
310
|
+
refresh_token?: string;
|
|
311
|
+
expires_in: number;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Notify caller of refreshed tokens so they can persist them
|
|
315
|
+
onTokensRefreshed?.({
|
|
316
|
+
accessToken: data.access_token,
|
|
317
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
console.log("[linear-agent] OAuth token refreshed successfully");
|
|
321
|
+
return data.access_token;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error("[linear-agent] Token refresh error:", err);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Exchange an authorization code for tokens (used during OAuth callback). */
|
|
329
|
+
export async function exchangeCodeForTokens(
|
|
330
|
+
creds: Pick<LinearOAuthCredentials, "clientId" | "clientSecret">,
|
|
331
|
+
code: string,
|
|
332
|
+
redirectUri: string,
|
|
333
|
+
): Promise<{ accessToken: string; refreshToken: string } | null> {
|
|
334
|
+
const { clientId: linearOAuthClientId, clientSecret: linearOAuthClientSecret } = creds;
|
|
335
|
+
|
|
336
|
+
if (!linearOAuthClientId || !linearOAuthClientSecret) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const response = await fetch("https://api.linear.app/oauth/token", {
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
344
|
+
body: new URLSearchParams({
|
|
345
|
+
grant_type: "authorization_code",
|
|
346
|
+
code,
|
|
347
|
+
redirect_uri: redirectUri,
|
|
348
|
+
client_id: linearOAuthClientId,
|
|
349
|
+
client_secret: linearOAuthClientSecret,
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
const text = await response.text().catch(() => "");
|
|
355
|
+
console.error("[linear-agent] Token exchange failed:", response.status, text);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const data = await response.json() as {
|
|
360
|
+
access_token: string;
|
|
361
|
+
refresh_token: string;
|
|
362
|
+
expires_in: number;
|
|
363
|
+
scope: string;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
accessToken: data.access_token,
|
|
368
|
+
refreshToken: data.refresh_token,
|
|
369
|
+
};
|
|
370
|
+
} catch (err) {
|
|
371
|
+
console.error("[linear-agent] Token exchange error:", err);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Webhook verification ─────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/** Verify a Linear webhook signature using HMAC-SHA256. */
|
|
379
|
+
export function verifyWebhookSignature(webhookSecret: string, body: string, signature: string | null): boolean {
|
|
380
|
+
const secret = webhookSecret;
|
|
381
|
+
|
|
382
|
+
if (!secret || !signature) return false;
|
|
383
|
+
|
|
384
|
+
// Validate signature is a valid 64-char hex string (SHA-256 output)
|
|
385
|
+
if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
|
|
386
|
+
|
|
387
|
+
const computed = createHmac("sha256", secret).update(body).digest("hex");
|
|
388
|
+
return timingSafeEqual(Buffer.from(computed, "hex"), Buffer.from(signature, "hex"));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── Agent Activities ─────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
/** Post an agent activity to a Linear agent session. */
|
|
394
|
+
export async function postActivity(
|
|
395
|
+
creds: LinearOAuthCredentials,
|
|
396
|
+
agentSessionId: string,
|
|
397
|
+
content: AgentActivityContent,
|
|
398
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
const result = await linearGraphQL<{ agentActivityCreate?: { success: boolean } }>(
|
|
401
|
+
creds,
|
|
402
|
+
`mutation CompanionAgentActivity($input: AgentActivityCreateInput!) {
|
|
403
|
+
agentActivityCreate(input: $input) { success }
|
|
404
|
+
}`,
|
|
405
|
+
{ input: { agentSessionId, content } },
|
|
406
|
+
onTokensRefreshed,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (result.errors?.length) {
|
|
410
|
+
console.error("[linear-agent] Activity creation failed:", result.errors[0].message);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Update the external URLs on an agent session (links back to Companion). */
|
|
415
|
+
export async function updateSessionUrls(
|
|
416
|
+
creds: LinearOAuthCredentials,
|
|
417
|
+
agentSessionId: string,
|
|
418
|
+
urls: Array<{ label: string; url: string }>,
|
|
419
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
const result = await linearGraphQL<{ agentSessionUpdate?: { success: boolean } }>(
|
|
422
|
+
creds,
|
|
423
|
+
`mutation CompanionAgentSessionUpdate($id: String!, $input: AgentSessionUpdateInput!) {
|
|
424
|
+
agentSessionUpdate(id: $id, input: $input) { success }
|
|
425
|
+
}`,
|
|
426
|
+
{ id: agentSessionId, input: { externalUrls: urls } },
|
|
427
|
+
onTokensRefreshed,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (result.errors?.length) {
|
|
431
|
+
console.error("[linear-agent] Session URL update failed:", result.errors[0].message);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Update the plan (checklist) on an agent session. */
|
|
436
|
+
export async function updateSessionPlan(
|
|
437
|
+
creds: LinearOAuthCredentials,
|
|
438
|
+
agentSessionId: string,
|
|
439
|
+
plan: AgentPlanItem[],
|
|
440
|
+
onTokensRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => void,
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
const result = await linearGraphQL<{ agentSessionUpdate?: { success: boolean } }>(
|
|
443
|
+
creds,
|
|
444
|
+
`mutation CompanionAgentPlanUpdate($id: String!, $input: AgentSessionUpdateInput!) {
|
|
445
|
+
agentSessionUpdate(id: $id, input: $input) { success }
|
|
446
|
+
}`,
|
|
447
|
+
{ id: agentSessionId, input: { plan } },
|
|
448
|
+
onTokensRefreshed,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
if (result.errors?.length) {
|
|
452
|
+
console.error("[linear-agent] Session plan update failed:", result.errors[0].message);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Check if Linear OAuth is fully configured (has client credentials + access token). */
|
|
457
|
+
export function isLinearOAuthConfigured(creds: Partial<LinearOAuthCredentials>): boolean {
|
|
458
|
+
return !!((creds.clientId || "").trim() && (creds.clientSecret || "").trim() && (creds.accessToken || "").trim());
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Get the OAuth authorization URL for installing the app with actor=app.
|
|
462
|
+
* Pass `returnTo` to redirect back to a specific page after the OAuth callback.
|
|
463
|
+
* Pass `stagingId` to associate the OAuth flow with a specific staging slot.
|
|
464
|
+
* Pass `connectionId` to store tokens directly in an OAuth connection. */
|
|
465
|
+
export function getOAuthAuthorizeUrl(clientId: string, redirectUri: string, options?: { returnTo?: string; stagingId?: string; connectionId?: string }): { url: string; state: string } | null {
|
|
466
|
+
if (!clientId) return null;
|
|
467
|
+
|
|
468
|
+
const state = generateOAuthState({ stagingId: options?.stagingId, connectionId: options?.connectionId, returnTo: options?.returnTo });
|
|
469
|
+
const params = new URLSearchParams({
|
|
470
|
+
client_id: clientId,
|
|
471
|
+
redirect_uri: redirectUri,
|
|
472
|
+
response_type: "code",
|
|
473
|
+
scope: "read,write,issues:create,comments:create,app:mentionable",
|
|
474
|
+
actor: "app",
|
|
475
|
+
state,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return { url: `https://linear.app/oauth/authorize?${params.toString()}`, state };
|
|
479
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { LinearCache } from "./linear-cache.js";
|
|
3
|
+
|
|
4
|
+
describe("LinearCache", () => {
|
|
5
|
+
let cache: LinearCache;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
cache = new LinearCache();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns fetched data and caches it", async () => {
|
|
12
|
+
const fetcher = vi.fn().mockResolvedValue({ issues: ["a"] });
|
|
13
|
+
const result = await cache.getOrFetch("key1", 5000, fetcher);
|
|
14
|
+
expect(result).toEqual({ issues: ["a"] });
|
|
15
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("serves from cache within TTL without calling fetcher again", async () => {
|
|
19
|
+
const fetcher = vi.fn().mockResolvedValue("data");
|
|
20
|
+
await cache.getOrFetch("k", 5000, fetcher);
|
|
21
|
+
const result = await cache.getOrFetch("k", 5000, fetcher);
|
|
22
|
+
expect(result).toBe("data");
|
|
23
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("re-fetches after TTL expires", async () => {
|
|
27
|
+
// Use fake timers to test TTL expiration
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
const fetcher = vi.fn()
|
|
30
|
+
.mockResolvedValueOnce("old")
|
|
31
|
+
.mockResolvedValueOnce("new");
|
|
32
|
+
|
|
33
|
+
await cache.getOrFetch("k", 100, fetcher);
|
|
34
|
+
|
|
35
|
+
// Advance past TTL
|
|
36
|
+
vi.advanceTimersByTime(150);
|
|
37
|
+
|
|
38
|
+
const result = await cache.getOrFetch("k", 100, fetcher);
|
|
39
|
+
expect(result).toBe("new");
|
|
40
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
41
|
+
|
|
42
|
+
vi.useRealTimers();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("deduplicates concurrent requests — fetcher called only once", async () => {
|
|
46
|
+
// Fetcher that resolves after a small delay to simulate network latency
|
|
47
|
+
let resolvePromise: (v: string) => void;
|
|
48
|
+
const fetcher = vi.fn().mockImplementation(
|
|
49
|
+
() => new Promise<string>((resolve) => { resolvePromise = resolve; }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const p1 = cache.getOrFetch("k", 5000, fetcher);
|
|
53
|
+
const p2 = cache.getOrFetch("k", 5000, fetcher);
|
|
54
|
+
|
|
55
|
+
// Both should be the same promise, fetcher called only once
|
|
56
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
57
|
+
|
|
58
|
+
resolvePromise!("shared");
|
|
59
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
60
|
+
expect(r1).toBe("shared");
|
|
61
|
+
expect(r2).toBe("shared");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("invalidates a specific key", async () => {
|
|
65
|
+
const fetcher = vi.fn()
|
|
66
|
+
.mockResolvedValueOnce("v1")
|
|
67
|
+
.mockResolvedValueOnce("v2");
|
|
68
|
+
|
|
69
|
+
await cache.getOrFetch("k", 60000, fetcher);
|
|
70
|
+
cache.invalidate("k");
|
|
71
|
+
|
|
72
|
+
const result = await cache.getOrFetch("k", 60000, fetcher);
|
|
73
|
+
expect(result).toBe("v2");
|
|
74
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("invalidates by prefix", async () => {
|
|
78
|
+
const fetcher1 = vi.fn().mockResolvedValue("a");
|
|
79
|
+
const fetcher2 = vi.fn().mockResolvedValue("b");
|
|
80
|
+
const fetcher3 = vi.fn().mockResolvedValue("c");
|
|
81
|
+
|
|
82
|
+
await cache.getOrFetch("issue:123", 60000, fetcher1);
|
|
83
|
+
await cache.getOrFetch("issue:456", 60000, fetcher2);
|
|
84
|
+
await cache.getOrFetch("search:hello", 60000, fetcher3);
|
|
85
|
+
|
|
86
|
+
cache.invalidate("issue:");
|
|
87
|
+
expect(cache.size).toBe(1); // only "search:hello" remains
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("clear() empties the entire cache", async () => {
|
|
91
|
+
await cache.getOrFetch("a", 60000, () => Promise.resolve(1));
|
|
92
|
+
await cache.getOrFetch("b", 60000, () => Promise.resolve(2));
|
|
93
|
+
expect(cache.size).toBe(2);
|
|
94
|
+
|
|
95
|
+
cache.clear();
|
|
96
|
+
expect(cache.size).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("failed fetch does not poison the cache — allows retry", async () => {
|
|
100
|
+
const fetcher = vi.fn()
|
|
101
|
+
.mockRejectedValueOnce(new Error("network fail"))
|
|
102
|
+
.mockResolvedValueOnce("recovered");
|
|
103
|
+
|
|
104
|
+
await expect(cache.getOrFetch("k", 5000, fetcher)).rejects.toThrow("network fail");
|
|
105
|
+
|
|
106
|
+
// Retry should call fetcher again, not serve the error
|
|
107
|
+
const result = await cache.getOrFetch("k", 5000, fetcher);
|
|
108
|
+
expect(result).toBe("recovered");
|
|
109
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("failed fetch with existing stale data keeps the stale entry for future retry", async () => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
|
|
115
|
+
const fetcher = vi.fn()
|
|
116
|
+
.mockResolvedValueOnce("stale-data")
|
|
117
|
+
.mockRejectedValueOnce(new Error("refresh failed"))
|
|
118
|
+
.mockResolvedValueOnce("fresh-data");
|
|
119
|
+
|
|
120
|
+
// Populate cache
|
|
121
|
+
await cache.getOrFetch("k", 100, fetcher);
|
|
122
|
+
|
|
123
|
+
// Expire the entry
|
|
124
|
+
vi.advanceTimersByTime(150);
|
|
125
|
+
|
|
126
|
+
// Attempt refresh — fails
|
|
127
|
+
await expect(cache.getOrFetch("k", 100, fetcher)).rejects.toThrow("refresh failed");
|
|
128
|
+
|
|
129
|
+
// Next attempt should retry the fetcher, not serve stale data
|
|
130
|
+
const result = await cache.getOrFetch("k", 100, fetcher);
|
|
131
|
+
expect(result).toBe("fresh-data");
|
|
132
|
+
expect(fetcher).toHaveBeenCalledTimes(3);
|
|
133
|
+
|
|
134
|
+
vi.useRealTimers();
|
|
135
|
+
});
|
|
136
|
+
});
|