@calltelemetry/openclaw-linear 0.8.2 → 0.8.4

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.
@@ -68,6 +68,12 @@ export async function runAgent(params: {
68
68
  message: string;
69
69
  timeoutMs?: number;
70
70
  streaming?: AgentStreamCallbacks;
71
+ /**
72
+ * Read-only mode: agent keeps read tools (read, glob, grep, web_search,
73
+ * web_fetch) but all write-capable tools are denied via config policy.
74
+ * Subprocess fallback is blocked — only the embedded runner is safe.
75
+ */
76
+ readOnly?: boolean;
71
77
  }): Promise<AgentRunResult> {
72
78
  const maxAttempts = 2;
73
79
 
@@ -126,8 +132,9 @@ async function runAgentOnce(params: {
126
132
  message: string;
127
133
  timeoutMs?: number;
128
134
  streaming?: AgentStreamCallbacks;
135
+ readOnly?: boolean;
129
136
  }): Promise<AgentRunResult> {
130
- const { api, agentId, sessionId, streaming } = params;
137
+ const { api, agentId, sessionId, streaming, readOnly } = params;
131
138
 
132
139
  // Inject current timestamp into every LLM request
133
140
  const message = `${buildDateContext()}\n\n${params.message}`;
@@ -136,24 +143,60 @@ async function runAgentOnce(params: {
136
143
  const wdConfig = resolveWatchdogConfig(agentId, pluginConfig);
137
144
  const timeoutMs = params.timeoutMs ?? wdConfig.maxTotalMs;
138
145
 
139
- api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s)`);
146
+ api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s${readOnly ? ", mode=READ_ONLY" : ""})`);
140
147
 
141
148
  // Try embedded runner first (has streaming callbacks)
142
149
  if (streaming) {
143
150
  try {
144
- return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs);
151
+ return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly);
145
152
  } catch (err) {
153
+ // Read-only mode MUST NOT fall back to subprocess — subprocess runs a
154
+ // full agent with no way to enforce the tool deny policy.
155
+ if (readOnly) {
156
+ api.logger.error(`Embedded runner failed in read-only mode, refusing subprocess fallback: ${err}`);
157
+ return { success: false, output: "Read-only agent run failed (embedded runner unavailable)." };
158
+ }
146
159
  api.logger.warn(`Embedded runner failed, falling back to subprocess: ${err}`);
147
160
  }
148
161
  }
149
162
 
150
163
  // Fallback: subprocess (no streaming)
164
+ if (readOnly) {
165
+ api.logger.error("Cannot run read-only agent via subprocess — no tool policy enforcement");
166
+ return { success: false, output: "Read-only agent run requires the embedded runner." };
167
+ }
151
168
  return runSubprocess(api, agentId, sessionId, message, timeoutMs);
152
169
  }
153
170
 
154
171
  /**
155
172
  * Embedded agent runner with real-time streaming to Linear and inactivity watchdog.
156
173
  */
174
+ // Tools denied in read-only mode. Uses OpenClaw group:* shorthands where
175
+ // possible (see https://docs.openclaw.ai/tools). Covers every built-in
176
+ // tool that can mutate the filesystem, execute commands, or produce
177
+ // side-effects beyond the Linear API calls the plugin makes after the run.
178
+ //
179
+ // NOT denied (read-only tools the triage agent keeps):
180
+ // read, glob, grep/search — codebase inspection
181
+ // group:web (web_search, web_fetch) — external context
182
+ // group:memory (memory_search/get) — knowledge retrieval
183
+ // sessions_list, sessions_history — read-only introspection
184
+ const READ_ONLY_DENY: string[] = [
185
+ // group:fs = read + write + edit + apply_patch — but we need read,
186
+ // so deny the write-capable members individually.
187
+ "write", "edit", "apply_patch",
188
+ // Full groups that are entirely write/side-effect oriented:
189
+ "group:runtime", // exec, bash, process
190
+ "group:messaging", // message
191
+ "group:ui", // browser, canvas
192
+ "group:automation", // cron, gateway
193
+ "group:nodes", // nodes
194
+ // Individual tools not covered by a group:
195
+ "sessions_spawn", "sessions_send", // agent orchestration
196
+ "tts", // audio file generation
197
+ "image", // image file generation
198
+ ];
199
+
157
200
  async function runEmbedded(
158
201
  api: OpenClawPluginApi,
159
202
  agentId: string,
@@ -162,12 +205,26 @@ async function runEmbedded(
162
205
  timeoutMs: number,
163
206
  streaming: AgentStreamCallbacks,
164
207
  inactivityMs: number,
208
+ readOnly?: boolean,
165
209
  ): Promise<AgentRunResult> {
166
210
  const ext = await getExtensionAPI();
167
211
 
168
212
  // Load config so we can resolve agent dirs and providers correctly.
169
- const config = await api.runtime.config.loadConfig();
170
- const configAny = config as Record<string, any>;
213
+ let config = await api.runtime.config.loadConfig();
214
+ let configAny = config as Record<string, any>;
215
+
216
+ // ── Read-only enforcement ──────────────────────────────────────────
217
+ // Clone the config and inject a tools.deny policy that strips every
218
+ // write-capable tool. The deny list is merged with any existing deny
219
+ // entries so we don't clobber operator-level restrictions.
220
+ if (readOnly) {
221
+ configAny = JSON.parse(JSON.stringify(configAny));
222
+ config = configAny as typeof config;
223
+ if (!configAny.tools) configAny.tools = {};
224
+ const existing: string[] = Array.isArray(configAny.tools.deny) ? configAny.tools.deny : [];
225
+ configAny.tools.deny = [...new Set([...existing, ...READ_ONLY_DENY])];
226
+ api.logger.info(`Read-only mode: tools.deny = [${configAny.tools.deny.join(", ")}]`);
227
+ }
171
228
 
172
229
  // Resolve workspace and agent dirs from config (ext API ignores agentId).
173
230
  const dirs = resolveAgentDirs(agentId, configAny);
@@ -233,6 +290,13 @@ async function runEmbedded(
233
290
  abortSignal: controller.signal,
234
291
  shouldEmitToolResult: () => true,
235
292
  shouldEmitToolOutput: () => true,
293
+ ...(readOnly ? {
294
+ extraSystemPrompt: [
295
+ "READ-ONLY MODE: You may read and search files but you MUST NOT",
296
+ "write, edit, create, or delete any files. Do not run shell commands.",
297
+ "Your only output is your text response.",
298
+ ].join(" "),
299
+ } : {}),
236
300
 
237
301
  // Stream reasoning/thinking to Linear
238
302
  onReasoningStream: (payload) => {
@@ -232,6 +232,43 @@ describe("LinearAgentApi", () => {
232
232
  );
233
233
  });
234
234
 
235
+ it("returns data when GraphQL errors and data coexist (partial success)", async () => {
236
+ // Simulates createAsUser returning warnings alongside valid comment data.
237
+ // This is the root cause of Bug 2 in API-477: gql() used to throw on
238
+ // any errors, even when the mutation succeeded and data was present.
239
+ fetchMock.mockResolvedValueOnce({
240
+ ok: true,
241
+ status: 200,
242
+ json: () =>
243
+ Promise.resolve({
244
+ data: {
245
+ commentCreate: { success: true, comment: { id: "c-partial" } },
246
+ },
247
+ errors: [{ message: "createAsUser: user not found, using default" }],
248
+ }),
249
+ text: () => Promise.resolve(""),
250
+ headers: new Headers(),
251
+ } as unknown as Response);
252
+
253
+ const api = new LinearAgentApi(TOKEN);
254
+ const id = await api.createComment("issue-1", "test body", {
255
+ createAsUser: "NonexistentUser",
256
+ });
257
+
258
+ expect(id).toBe("c-partial");
259
+ });
260
+
261
+ it("still throws on GraphQL errors when no data is present", async () => {
262
+ fetchMock.mockResolvedValueOnce(
263
+ gqlErrorResponse([{ message: "Totally broken" }]),
264
+ );
265
+
266
+ const api = new LinearAgentApi(TOKEN);
267
+ await expect(
268
+ api.createComment("issue-1", "test body"),
269
+ ).rejects.toThrow(/Linear GraphQL/);
270
+ });
271
+
235
272
  it("retries on 401 when refresh token is available", async () => {
236
273
  // First call (via withResilience): 401
237
274
  fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
@@ -193,7 +193,7 @@ export class LinearAgentApi {
193
193
  }
194
194
 
195
195
  const payload = await retryRes.json();
196
- if (payload.errors?.length) {
196
+ if (payload.errors?.length && !payload.data) {
197
197
  throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
198
198
  }
199
199
  return payload.data as T;
@@ -205,7 +205,7 @@ export class LinearAgentApi {
205
205
  }
206
206
 
207
207
  const payload = await res.json();
208
- if (payload.errors?.length) {
208
+ if (payload.errors?.length && !payload.data) {
209
209
  throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
210
210
  }
211
211
 
@@ -306,7 +306,7 @@ export class LinearAgentApi {
306
306
  title: string;
307
307
  description: string | null;
308
308
  estimate: number | null;
309
- state: { name: string };
309
+ state: { name: string; type: string };
310
310
  assignee: { name: string } | null;
311
311
  labels: { nodes: Array<{ id: string; name: string }> };
312
312
  team: { id: string; name: string; issueEstimationType: string };
@@ -323,7 +323,7 @@ export class LinearAgentApi {
323
323
  title
324
324
  description
325
325
  estimate
326
- state { name }
326
+ state { name type }
327
327
  assignee { name }
328
328
  labels { nodes { id name } }
329
329
  team { id name issueEstimationType }
@@ -533,7 +533,7 @@ export class LinearAgentApi {
533
533
  }>(
534
534
  `query TeamStates($id: String!) {
535
535
  team(id: $id) {
536
- states: workflowStates { nodes { id name type } }
536
+ states { nodes { id name type } }
537
537
  }
538
538
  }`,
539
539
  { id: teamId },
@@ -591,4 +591,95 @@ export class LinearAgentApi {
591
591
  );
592
592
  return data.notifications.nodes as any;
593
593
  }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Webhook management
597
+ // ---------------------------------------------------------------------------
598
+
599
+ async listWebhooks(): Promise<Array<{
600
+ id: string;
601
+ label: string | null;
602
+ url: string;
603
+ enabled: boolean;
604
+ resourceTypes: string[];
605
+ allPublicTeams: boolean;
606
+ team: { id: string; name: string } | null;
607
+ createdAt: string;
608
+ }>> {
609
+ const data = await this.gql<{
610
+ webhooks: { nodes: unknown[] };
611
+ }>(
612
+ `query Webhooks {
613
+ webhooks {
614
+ nodes {
615
+ id
616
+ label
617
+ url
618
+ enabled
619
+ resourceTypes
620
+ allPublicTeams
621
+ team { id name }
622
+ createdAt
623
+ }
624
+ }
625
+ }`,
626
+ );
627
+ return data.webhooks.nodes as any;
628
+ }
629
+
630
+ async createWebhook(input: {
631
+ url: string;
632
+ resourceTypes: string[];
633
+ label?: string;
634
+ teamId?: string;
635
+ allPublicTeams?: boolean;
636
+ enabled?: boolean;
637
+ secret?: string;
638
+ }): Promise<{ id: string; enabled: boolean }> {
639
+ const data = await this.gql<{
640
+ webhookCreate: { success: boolean; webhook: { id: string; enabled: boolean } };
641
+ }>(
642
+ `mutation WebhookCreate($input: WebhookCreateInput!) {
643
+ webhookCreate(input: $input) {
644
+ success
645
+ webhook { id enabled }
646
+ }
647
+ }`,
648
+ { input },
649
+ );
650
+ return data.webhookCreate.webhook;
651
+ }
652
+
653
+ async updateWebhook(webhookId: string, input: {
654
+ url?: string;
655
+ resourceTypes?: string[];
656
+ label?: string;
657
+ enabled?: boolean;
658
+ }): Promise<boolean> {
659
+ const data = await this.gql<{
660
+ webhookUpdate: { success: boolean };
661
+ }>(
662
+ `mutation WebhookUpdate($id: String!, $input: WebhookUpdateInput!) {
663
+ webhookUpdate(id: $id, input: $input) {
664
+ success
665
+ }
666
+ }`,
667
+ { id: webhookId, input },
668
+ );
669
+ return data.webhookUpdate.success;
670
+ }
671
+
672
+ async deleteWebhook(webhookId: string): Promise<boolean> {
673
+ const data = await this.gql<{
674
+ webhookDelete: { success: boolean };
675
+ }>(
676
+ `mutation WebhookDelete($id: String!) {
677
+ webhookDelete(id: $id) {
678
+ success
679
+ }
680
+ }`,
681
+ { id: webhookId },
682
+ );
683
+ return data.webhookDelete.success;
684
+ }
594
685
  }
package/src/infra/cli.ts CHANGED
@@ -644,6 +644,156 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
644
644
  process.exitCode = 1;
645
645
  }
646
646
  });
647
+
648
+ // --- openclaw openclaw-linear webhooks ---
649
+ const webhooksCmd = linear
650
+ .command("webhooks")
651
+ .description("Manage Linear webhook subscriptions");
652
+
653
+ webhooksCmd
654
+ .command("status")
655
+ .description("Show current webhook configuration in Linear")
656
+ .action(async () => {
657
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
658
+ const tokenInfo = resolveLinearToken(pluginConfig);
659
+ if (!tokenInfo.accessToken) {
660
+ console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
661
+ process.exitCode = 1;
662
+ return;
663
+ }
664
+
665
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
666
+ refreshToken: tokenInfo.refreshToken,
667
+ expiresAt: tokenInfo.expiresAt,
668
+ });
669
+
670
+ console.log("\nLinear Webhooks");
671
+ console.log("─".repeat(50));
672
+
673
+ const webhooks = await linearApi.listWebhooks();
674
+ if (webhooks.length === 0) {
675
+ console.log("\n No webhooks found.");
676
+ console.log(" Run \"openclaw openclaw-linear webhooks setup\" to create one.\n");
677
+ return;
678
+ }
679
+
680
+ for (const wh of webhooks) {
681
+ const status = wh.enabled ? "enabled" : "DISABLED";
682
+ const label = wh.label ?? "(no label)";
683
+ const team = wh.team ? ` (team: ${wh.team.name})` : " (all teams)";
684
+ console.log(`\n ${label}${team}`);
685
+ console.log(` ID: ${wh.id}`);
686
+ console.log(` URL: ${wh.url}`);
687
+ console.log(` Status: ${status}`);
688
+ console.log(` Events: ${wh.resourceTypes.join(", ")}`);
689
+ console.log(` Created: ${wh.createdAt}`);
690
+ }
691
+
692
+ console.log();
693
+ });
694
+
695
+ webhooksCmd
696
+ .command("setup")
697
+ .description("Auto-provision or fix the workspace webhook (create/update as needed)")
698
+ .option("--url <url>", "Webhook URL (default: from Cloudflare tunnel config)")
699
+ .option("--team <id>", "Restrict to a specific team ID (default: all public teams)")
700
+ .option("--dry-run", "Show what would change without making changes")
701
+ .action(async (opts: { url?: string; team?: string; dryRun?: boolean }) => {
702
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
703
+ const { provisionWebhook, getWebhookStatus, REQUIRED_RESOURCE_TYPES } = await import("./webhook-provision.js");
704
+
705
+ const tokenInfo = resolveLinearToken(pluginConfig);
706
+ if (!tokenInfo.accessToken) {
707
+ console.error("\n No Linear token found. Run \"openclaw openclaw-linear auth\" first.\n");
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+
712
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
713
+ refreshToken: tokenInfo.refreshToken,
714
+ expiresAt: tokenInfo.expiresAt,
715
+ });
716
+
717
+ const webhookUrl = opts.url
718
+ ?? (pluginConfig?.webhookUrl as string)
719
+ ?? "https://linear.calltelemetry.com/linear/webhook";
720
+
721
+ console.log("\nWebhook Provisioning");
722
+ console.log("─".repeat(50));
723
+ console.log(` URL: ${webhookUrl}`);
724
+ console.log(` Events: ${[...REQUIRED_RESOURCE_TYPES].join(", ")}`);
725
+
726
+ if (opts.dryRun) {
727
+ const status = await getWebhookStatus(linearApi, webhookUrl);
728
+ if (!status) {
729
+ console.log("\n Would CREATE a new webhook with the above config.");
730
+ } else if (status.issues.length === 0) {
731
+ console.log("\n Webhook already configured correctly. No changes needed.");
732
+ } else {
733
+ console.log("\n Would UPDATE existing webhook:");
734
+ for (const issue of status.issues) {
735
+ console.log(` - Fix: ${issue}`);
736
+ }
737
+ }
738
+ console.log();
739
+ return;
740
+ }
741
+
742
+ const result = await provisionWebhook(linearApi, webhookUrl, {
743
+ teamId: opts.team,
744
+ allPublicTeams: !opts.team,
745
+ });
746
+
747
+ switch (result.action) {
748
+ case "created":
749
+ console.log(`\n Created webhook: ${result.webhookId}`);
750
+ break;
751
+ case "updated":
752
+ console.log(`\n Updated webhook: ${result.webhookId}`);
753
+ for (const change of result.changes ?? []) {
754
+ console.log(` - ${change}`);
755
+ }
756
+ break;
757
+ case "already_ok":
758
+ console.log(`\n Webhook already configured correctly (${result.webhookId}). No changes needed.`);
759
+ break;
760
+ }
761
+
762
+ console.log();
763
+ });
764
+
765
+ webhooksCmd
766
+ .command("delete")
767
+ .description("Delete a webhook by ID")
768
+ .argument("<webhook-id>", "ID of the webhook to delete")
769
+ .action(async (webhookId: string) => {
770
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
771
+ const tokenInfo = resolveLinearToken(pluginConfig);
772
+ if (!tokenInfo.accessToken) {
773
+ console.error("\n No Linear token found.\n");
774
+ process.exitCode = 1;
775
+ return;
776
+ }
777
+
778
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
779
+ refreshToken: tokenInfo.refreshToken,
780
+ expiresAt: tokenInfo.expiresAt,
781
+ });
782
+
783
+ const confirmAnswer = await prompt(`Delete webhook ${webhookId}? [y/N]: `);
784
+ if (confirmAnswer.toLowerCase() !== "y") {
785
+ console.log(" Aborted.\n");
786
+ return;
787
+ }
788
+
789
+ const success = await linearApi.deleteWebhook(webhookId);
790
+ if (success) {
791
+ console.log(`\n Deleted webhook ${webhookId}\n`);
792
+ } else {
793
+ console.error(`\n Failed to delete webhook ${webhookId}\n`);
794
+ process.exitCode = 1;
795
+ }
796
+ });
647
797
  }
648
798
 
649
799
  // ---------------------------------------------------------------------------
@@ -11,6 +11,9 @@ vi.mock("../api/linear-api.js", () => ({
11
11
  expiresAt: Date.now() + 24 * 3_600_000,
12
12
  source: "profile" as const,
13
13
  })),
14
+ LinearAgentApi: class MockLinearAgentApi {
15
+ constructor() {}
16
+ },
14
17
  AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
15
18
  LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
16
19
  }));
@@ -57,6 +60,16 @@ vi.mock("../tools/code-tool.js", () => ({
57
60
  })),
58
61
  }));
59
62
 
63
+ vi.mock("./webhook-provision.js", () => ({
64
+ getWebhookStatus: vi.fn(async () => null),
65
+ provisionWebhook: vi.fn(async () => ({
66
+ action: "created",
67
+ webhookId: "wh-test-1",
68
+ changes: ["created new webhook"],
69
+ })),
70
+ REQUIRED_RESOURCE_TYPES: ["Comment", "Issue"],
71
+ }));
72
+
60
73
  import {
61
74
  checkAuth,
62
75
  checkAgentConfig,
@@ -64,6 +77,7 @@ import {
64
77
  checkFilesAndDirs,
65
78
  checkConnectivity,
66
79
  checkDispatchHealth,
80
+ checkWebhooks,
67
81
  runDoctor,
68
82
  formatReport,
69
83
  formatReportJson,
@@ -330,7 +344,7 @@ describe("checkDispatchHealth", () => {
330
344
  // ---------------------------------------------------------------------------
331
345
 
332
346
  describe("runDoctor", () => {
333
- it("returns all 6 sections", async () => {
347
+ it("returns all 7 sections", async () => {
334
348
  vi.stubGlobal("fetch", vi.fn(async (url: string) => {
335
349
  if (url.includes("linear.app")) {
336
350
  return {
@@ -342,7 +356,7 @@ describe("runDoctor", () => {
342
356
  }));
343
357
 
344
358
  const report = await runDoctor({ fix: false, json: false });
345
- expect(report.sections).toHaveLength(6);
359
+ expect(report.sections).toHaveLength(7);
346
360
  expect(report.sections.map((s) => s.name)).toEqual([
347
361
  "Authentication & Tokens",
348
362
  "Agent Configuration",
@@ -350,6 +364,7 @@ describe("runDoctor", () => {
350
364
  "Files & Directories",
351
365
  "Connectivity",
352
366
  "Dispatch Health",
367
+ "Webhook Configuration",
353
368
  ]);
354
369
  expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
355
370
  });
@@ -8,11 +8,12 @@ import { join } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execFileSync } from "node:child_process";
10
10
 
11
- import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
11
+ import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
12
12
  import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
13
13
  import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
14
14
  import { listWorktrees } from "./codex-worktree.js";
15
15
  import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
16
+ import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Types
@@ -650,6 +651,68 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
650
651
  return checks;
651
652
  }
652
653
 
654
+ // ---------------------------------------------------------------------------
655
+ // Section 7: Webhook Configuration
656
+ // ---------------------------------------------------------------------------
657
+
658
+ export async function checkWebhooks(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
659
+ const checks: CheckResult[] = [];
660
+
661
+ const tokenInfo = resolveLinearToken(pluginConfig);
662
+ if (!tokenInfo.accessToken) {
663
+ checks.push(warn("Webhook check skipped (no Linear token)"));
664
+ return checks;
665
+ }
666
+
667
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
668
+ refreshToken: tokenInfo.refreshToken,
669
+ expiresAt: tokenInfo.expiresAt,
670
+ });
671
+
672
+ const webhookUrl = (pluginConfig?.webhookUrl as string)
673
+ ?? "https://linear.calltelemetry.com/linear/webhook";
674
+
675
+ try {
676
+ const status = await getWebhookStatus(linearApi, webhookUrl);
677
+
678
+ if (!status) {
679
+ if (fix) {
680
+ const result = await provisionWebhook(linearApi, webhookUrl, { allPublicTeams: true });
681
+ checks.push(pass(`Workspace webhook created (${result.webhookId}) (--fix)`));
682
+ } else {
683
+ checks.push(fail(
684
+ `No workspace webhook found for ${webhookUrl}`,
685
+ undefined,
686
+ 'Run: openclaw openclaw-linear webhooks setup',
687
+ ));
688
+ }
689
+ return checks;
690
+ }
691
+
692
+ if (status.issues.length === 0) {
693
+ checks.push(pass(`Workspace webhook OK (${[...REQUIRED_RESOURCE_TYPES].join(", ")})`));
694
+ } else {
695
+ if (fix) {
696
+ const result = await provisionWebhook(linearApi, webhookUrl);
697
+ const changes = result.changes?.join(", ") ?? "fixed";
698
+ checks.push(pass(`Workspace webhook fixed: ${changes} (--fix)`));
699
+ } else {
700
+ for (const issue of status.issues) {
701
+ checks.push(warn(
702
+ `Webhook issue: ${issue}`,
703
+ undefined,
704
+ { fixable: true, fix: 'Run: openclaw openclaw-linear webhooks setup' },
705
+ ));
706
+ }
707
+ }
708
+ }
709
+ } catch (err) {
710
+ checks.push(warn(`Webhook check failed: ${err instanceof Error ? err.message : String(err)}`));
711
+ }
712
+
713
+ return checks;
714
+ }
715
+
653
716
  // ---------------------------------------------------------------------------
654
717
  // Main entry point
655
718
  // ---------------------------------------------------------------------------
@@ -685,6 +748,12 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
685
748
  checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
686
749
  });
687
750
 
751
+ // 7. Webhook configuration (auto-fix if --fix)
752
+ sections.push({
753
+ name: "Webhook Configuration",
754
+ checks: await checkWebhooks(opts.pluginConfig, opts.fix),
755
+ });
756
+
688
757
  // Fix: chmod auth-profiles.json if needed
689
758
  if (opts.fix) {
690
759
  const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));