@calltelemetry/openclaw-linear 0.8.2 → 0.8.3

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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * webhook-scenarios.test.ts — Full handler flow tests using captured payloads.
3
+ *
4
+ * Replays webhook payloads through handleLinearWebhook with mocked API
5
+ * dependencies. Tests the complete async handler behavior for each event
6
+ * type: API calls made, session lifecycle, agent runs, and responses.
7
+ *
8
+ * Unlike webhook-dedup.test.ts (dedup logic) and webhook.test.ts (HTTP basics),
9
+ * these tests verify the full business logic paths end-to-end.
10
+ *
11
+ * Key pattern: handlers prefer emitActivity(response) over createComment
12
+ * when an agent session exists — createComment is only used as a fallback
13
+ * when the session activity emission fails.
14
+ */
15
+ import type { AddressInfo } from "node:net";
16
+ import { createServer } from "node:http";
17
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ // ── Hoisted mock references ──────────────────────────────────────
21
+
22
+ const {
23
+ mockRunAgent,
24
+ mockGetViewerId,
25
+ mockGetIssueDetails,
26
+ mockCreateComment,
27
+ mockEmitActivity,
28
+ mockUpdateSession,
29
+ mockUpdateIssue,
30
+ mockGetTeamLabels,
31
+ mockCreateSessionOnIssue,
32
+ mockClassifyIntent,
33
+ mockSpawnWorker,
34
+ mockSetActiveSession,
35
+ mockClearActiveSession,
36
+ mockEmitDiagnostic,
37
+ } = vi.hoisted(() => ({
38
+ mockRunAgent: vi.fn(),
39
+ mockGetViewerId: vi.fn(),
40
+ mockGetIssueDetails: vi.fn(),
41
+ mockCreateComment: vi.fn(),
42
+ mockEmitActivity: vi.fn(),
43
+ mockUpdateSession: vi.fn(),
44
+ mockUpdateIssue: vi.fn(),
45
+ mockGetTeamLabels: vi.fn(),
46
+ mockCreateSessionOnIssue: vi.fn(),
47
+ mockClassifyIntent: vi.fn(),
48
+ mockSpawnWorker: vi.fn(),
49
+ mockSetActiveSession: vi.fn(),
50
+ mockClearActiveSession: vi.fn(),
51
+ mockEmitDiagnostic: vi.fn(),
52
+ }));
53
+
54
+ // ── Module mocks (must precede all imports of tested code) ───────
55
+
56
+ vi.mock("../agent/agent.js", () => ({
57
+ runAgent: mockRunAgent,
58
+ }));
59
+
60
+ vi.mock("../api/linear-api.js", () => ({
61
+ LinearAgentApi: class MockLinearAgentApi {
62
+ emitActivity = mockEmitActivity;
63
+ createComment = mockCreateComment;
64
+ getIssueDetails = mockGetIssueDetails;
65
+ updateSession = mockUpdateSession;
66
+ getViewerId = mockGetViewerId;
67
+ updateIssue = mockUpdateIssue;
68
+ getTeamLabels = mockGetTeamLabels;
69
+ createSessionOnIssue = mockCreateSessionOnIssue;
70
+ },
71
+ resolveLinearToken: vi.fn().mockReturnValue({
72
+ accessToken: "test-token",
73
+ source: "env",
74
+ }),
75
+ }));
76
+
77
+ vi.mock("../pipeline/pipeline.js", () => ({
78
+ spawnWorker: mockSpawnWorker,
79
+ runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
80
+ runFullPipeline: vi.fn().mockResolvedValue(undefined),
81
+ resumePipeline: vi.fn().mockResolvedValue(undefined),
82
+ }));
83
+
84
+ vi.mock("../pipeline/active-session.js", () => ({
85
+ setActiveSession: mockSetActiveSession,
86
+ clearActiveSession: mockClearActiveSession,
87
+ }));
88
+
89
+ vi.mock("../infra/observability.js", () => ({
90
+ emitDiagnostic: mockEmitDiagnostic,
91
+ }));
92
+
93
+ vi.mock("../pipeline/intent-classify.js", () => ({
94
+ classifyIntent: mockClassifyIntent,
95
+ }));
96
+
97
+ vi.mock("../pipeline/dispatch-state.js", () => ({
98
+ readDispatchState: vi.fn().mockResolvedValue({ dispatches: { active: {}, completed: {} }, sessionMap: {} }),
99
+ getActiveDispatch: vi.fn().mockReturnValue(null),
100
+ registerDispatch: vi.fn().mockResolvedValue(undefined),
101
+ updateDispatchStatus: vi.fn().mockResolvedValue(undefined),
102
+ completeDispatch: vi.fn().mockResolvedValue(undefined),
103
+ removeActiveDispatch: vi.fn().mockResolvedValue(undefined),
104
+ }));
105
+
106
+ vi.mock("../infra/notify.js", () => ({
107
+ createNotifierFromConfig: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
108
+ }));
109
+
110
+ vi.mock("../pipeline/tier-assess.js", () => ({
111
+ assessTier: vi.fn().mockResolvedValue({ tier: "standard", model: "test-model", reasoning: "mock assessment" }),
112
+ }));
113
+
114
+ vi.mock("../infra/codex-worktree.js", () => ({
115
+ createWorktree: vi.fn().mockReturnValue({ path: "/tmp/mock-worktree", branch: "codex/ENG-123", resumed: false }),
116
+ createMultiWorktree: vi.fn(),
117
+ prepareWorkspace: vi.fn().mockReturnValue({ pulled: false, submodulesInitialized: false, errors: [] }),
118
+ }));
119
+
120
+ vi.mock("../infra/multi-repo.js", () => ({
121
+ resolveRepos: vi.fn().mockReturnValue({ repos: [] }),
122
+ isMultiRepo: vi.fn().mockReturnValue(false),
123
+ }));
124
+
125
+ vi.mock("../pipeline/artifacts.js", () => ({
126
+ ensureClawDir: vi.fn(),
127
+ writeManifest: vi.fn(),
128
+ writeDispatchMemory: vi.fn(),
129
+ resolveOrchestratorWorkspace: vi.fn().mockReturnValue("/mock/workspace"),
130
+ }));
131
+
132
+ vi.mock("../pipeline/planning-state.js", () => ({
133
+ readPlanningState: vi.fn().mockResolvedValue({ sessions: {} }),
134
+ isInPlanningMode: vi.fn().mockReturnValue(false),
135
+ getPlanningSession: vi.fn().mockReturnValue(null),
136
+ endPlanningSession: vi.fn().mockResolvedValue(undefined),
137
+ }));
138
+
139
+ vi.mock("../pipeline/planner.js", () => ({
140
+ initiatePlanningSession: vi.fn().mockResolvedValue(undefined),
141
+ handlePlannerTurn: vi.fn().mockResolvedValue(undefined),
142
+ runPlanAudit: vi.fn().mockResolvedValue(undefined),
143
+ }));
144
+
145
+ vi.mock("../pipeline/dag-dispatch.js", () => ({
146
+ startProjectDispatch: vi.fn().mockResolvedValue(undefined),
147
+ }));
148
+
149
+ // ── Imports (after mocks) ────────────────────────────────────────
150
+
151
+ import { handleLinearWebhook, _resetForTesting } from "../pipeline/webhook.js";
152
+ import {
153
+ makeAgentSessionEventCreated,
154
+ makeAgentSessionEventPrompted,
155
+ makeCommentCreate,
156
+ makeCommentCreateFromBot,
157
+ makeIssueCreate,
158
+ makeIssueUpdateWithAssignment,
159
+ makeAppUserNotification,
160
+ } from "./fixtures/webhook-payloads.js";
161
+ import { makeIssueDetails } from "./fixtures/linear-responses.js";
162
+
163
+ // ── Helpers ──────────────────────────────────────────────────────
164
+
165
+ function createApi(): OpenClawPluginApi {
166
+ return {
167
+ logger: {
168
+ info: vi.fn(),
169
+ warn: vi.fn(),
170
+ error: vi.fn(),
171
+ debug: vi.fn(),
172
+ },
173
+ runtime: {},
174
+ pluginConfig: { defaultAgentId: "mal" },
175
+ } as unknown as OpenClawPluginApi;
176
+ }
177
+
178
+ async function withServer(
179
+ handler: Parameters<typeof createServer>[0],
180
+ fn: (baseUrl: string) => Promise<void>,
181
+ ) {
182
+ const server = createServer(handler);
183
+ await new Promise<void>((resolve) => {
184
+ server.listen(0, "127.0.0.1", () => resolve());
185
+ });
186
+ const address = server.address() as AddressInfo | null;
187
+ if (!address) throw new Error("missing server address");
188
+ try {
189
+ await fn(`http://127.0.0.1:${address.port}`);
190
+ } finally {
191
+ await new Promise<void>((resolve) => server.close(() => resolve()));
192
+ }
193
+ }
194
+
195
+ async function postWebhook(api: OpenClawPluginApi, payload: unknown) {
196
+ let status = 0;
197
+ let body = "";
198
+ await withServer(
199
+ async (req, res) => {
200
+ await handleLinearWebhook(api, req, res);
201
+ },
202
+ async (baseUrl) => {
203
+ const response = await fetch(`${baseUrl}/linear/webhook`, {
204
+ method: "POST",
205
+ headers: { "content-type": "application/json" },
206
+ body: JSON.stringify(payload),
207
+ });
208
+ status = response.status;
209
+ body = await response.text();
210
+ },
211
+ );
212
+ return { status, body };
213
+ }
214
+
215
+ function infoLogs(api: OpenClawPluginApi): string[] {
216
+ return (api.logger.info as ReturnType<typeof vi.fn>).mock.calls.map(
217
+ (c: unknown[]) => String(c[0]),
218
+ );
219
+ }
220
+
221
+ function errorLogs(api: OpenClawPluginApi): string[] {
222
+ return (api.logger.error as ReturnType<typeof vi.fn>).mock.calls.map(
223
+ (c: unknown[]) => String(c[0]),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Wait for a mock to be called within a timeout.
229
+ * Used for fire-and-forget `void (async () => {...})()` handlers.
230
+ */
231
+ async function waitForMock(
232
+ mock: ReturnType<typeof vi.fn>,
233
+ opts?: { timeout?: number; times?: number },
234
+ ): Promise<void> {
235
+ const timeout = opts?.timeout ?? 2000;
236
+ const times = opts?.times ?? 1;
237
+ await vi.waitFor(
238
+ () => { expect(mock).toHaveBeenCalledTimes(times); },
239
+ { timeout, interval: 50 },
240
+ );
241
+ }
242
+
243
+ /** Extract emitActivity calls that have a specific type (thought, response, error, action). */
244
+ function activityCallsOfType(type: string): unknown[][] {
245
+ return mockEmitActivity.mock.calls.filter(
246
+ (c: unknown[]) => (c[1] as any)?.type === type,
247
+ );
248
+ }
249
+
250
+ // ── Setup / Teardown ─────────────────────────────────────────────
251
+
252
+ beforeEach(() => {
253
+ vi.clearAllMocks();
254
+ _resetForTesting();
255
+
256
+ // Default mock behaviors
257
+ mockGetViewerId.mockResolvedValue("viewer-bot-1");
258
+ mockGetIssueDetails.mockResolvedValue(makeIssueDetails());
259
+ mockCreateComment.mockResolvedValue("comment-new-id");
260
+ mockEmitActivity.mockResolvedValue(undefined);
261
+ mockUpdateSession.mockResolvedValue(undefined);
262
+ mockUpdateIssue.mockResolvedValue(true);
263
+ mockCreateSessionOnIssue.mockResolvedValue({ sessionId: "session-mock-1" });
264
+ mockGetTeamLabels.mockResolvedValue([
265
+ { id: "label-bug", name: "Bug" },
266
+ { id: "label-feature", name: "Feature" },
267
+ ]);
268
+ mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
269
+ mockSpawnWorker.mockResolvedValue(undefined);
270
+ mockClassifyIntent.mockResolvedValue({
271
+ intent: "general",
272
+ reasoning: "test fallback",
273
+ fromFallback: true,
274
+ });
275
+ });
276
+
277
+ afterEach(() => {
278
+ _resetForTesting();
279
+ });
280
+
281
+ // ── Tests ────────────────────────────────────────────────────────
282
+
283
+ describe("webhook scenario tests — full handler flows", () => {
284
+ describe("AgentSessionEvent", () => {
285
+ it("created: runs agent, delivers response via emitActivity", async () => {
286
+ const api = createApi();
287
+ const payload = makeAgentSessionEventCreated();
288
+ const result = await postWebhook(api, payload);
289
+ expect(result.status).toBe(200);
290
+
291
+ // Wait for the fire-and-forget handler to complete
292
+ await waitForMock(mockClearActiveSession);
293
+
294
+ // Issue enrichment
295
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-1");
296
+
297
+ // Agent invoked with correct session/message
298
+ expect(mockRunAgent).toHaveBeenCalledOnce();
299
+ const runArgs = mockRunAgent.mock.calls[0][0];
300
+ expect(runArgs.sessionId).toContain("linear-session-sess-event-1");
301
+ expect(runArgs.message).toContain("ENG-123");
302
+
303
+ // emitActivity called with thought and response
304
+ expect(activityCallsOfType("thought").length).toBeGreaterThan(0);
305
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
306
+
307
+ // Response delivered via emitActivity (session-first pattern),
308
+ // NOT via createComment — avoids duplicate visible messages.
309
+ expect(mockCreateComment).not.toHaveBeenCalled();
310
+
311
+ // Session lifecycle
312
+ expect(mockSetActiveSession).toHaveBeenCalledWith(
313
+ expect.objectContaining({ issueId: "issue-1", agentSessionId: "sess-event-1" }),
314
+ );
315
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
316
+ });
317
+
318
+ it("created: falls back to createComment when emitActivity fails", async () => {
319
+ // Make the response emitActivity fail — comment is the fallback
320
+ let emitCallCount = 0;
321
+ mockEmitActivity.mockImplementation(async (_sessionId: string, content: any) => {
322
+ emitCallCount++;
323
+ // Let the "thought" emission succeed, but fail the "response" emission
324
+ if (content?.type === "response") {
325
+ throw new Error("session expired");
326
+ }
327
+ });
328
+
329
+ const api = createApi();
330
+ const payload = makeAgentSessionEventCreated();
331
+ await postWebhook(api, payload);
332
+
333
+ await waitForMock(mockClearActiveSession);
334
+
335
+ // runAgent was called
336
+ expect(mockRunAgent).toHaveBeenCalledOnce();
337
+
338
+ // emitActivity(response) failed → fell back to createComment
339
+ expect(mockCreateComment).toHaveBeenCalledOnce();
340
+ const commentBody = mockCreateComment.mock.calls[0][1] as string;
341
+ expect(commentBody).toContain("Agent response text");
342
+ });
343
+
344
+ it("prompted: processes follow-up, delivers via emitActivity", async () => {
345
+ const api = createApi();
346
+ const payload = makeAgentSessionEventPrompted();
347
+ const result = await postWebhook(api, payload);
348
+ expect(result.status).toBe(200);
349
+
350
+ await waitForMock(mockClearActiveSession);
351
+
352
+ expect(mockRunAgent).toHaveBeenCalledOnce();
353
+ const msg = mockRunAgent.mock.calls[0][0].message;
354
+ expect(msg).toContain("Follow-up question here");
355
+
356
+ // Response via emitActivity, not createComment
357
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
358
+ expect(mockCreateComment).not.toHaveBeenCalled();
359
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
360
+ });
361
+
362
+ it("created with missing data: logs error, no crash", async () => {
363
+ const api = createApi();
364
+ const payload = {
365
+ type: "AgentSessionEvent",
366
+ action: "created",
367
+ agentSession: { id: null, issue: null },
368
+ previousComments: [],
369
+ };
370
+ const result = await postWebhook(api, payload);
371
+ expect(result.status).toBe(200);
372
+
373
+ const errors = errorLogs(api);
374
+ expect(errors.some((e) => e.includes("missing session or issue"))).toBe(true);
375
+ expect(mockRunAgent).not.toHaveBeenCalled();
376
+ });
377
+ });
378
+
379
+ describe("Comment.create", () => {
380
+ it("ask_agent intent: dispatches to named agent", async () => {
381
+ mockClassifyIntent.mockResolvedValue({
382
+ intent: "ask_agent",
383
+ agentId: "mal",
384
+ reasoning: "user asking for work",
385
+ fromFallback: false,
386
+ });
387
+
388
+ const api = createApi();
389
+ const payload = makeCommentCreate({
390
+ data: {
391
+ id: "comment-intent-1",
392
+ body: "Can someone look at this issue?",
393
+ user: { id: "user-human", name: "Human" },
394
+ issue: {
395
+ id: "issue-intent-1",
396
+ identifier: "ENG-301",
397
+ title: "Intent test",
398
+ team: { id: "team-1" },
399
+ project: null,
400
+ },
401
+ createdAt: new Date().toISOString(),
402
+ },
403
+ });
404
+ await postWebhook(api, payload);
405
+
406
+ // Wait for the full dispatch to complete
407
+ await waitForMock(mockClearActiveSession);
408
+
409
+ expect(mockClassifyIntent).toHaveBeenCalledOnce();
410
+ expect(mockRunAgent).toHaveBeenCalledOnce();
411
+
412
+ // Session created → response via emitActivity
413
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
414
+ });
415
+
416
+ it("request_work intent: dispatches to default agent", async () => {
417
+ mockClassifyIntent.mockResolvedValue({
418
+ intent: "request_work",
419
+ reasoning: "user wants implementation",
420
+ fromFallback: false,
421
+ });
422
+
423
+ const api = createApi();
424
+ const payload = makeCommentCreate({
425
+ data: {
426
+ id: "comment-work-1",
427
+ body: "Please implement the login page",
428
+ user: { id: "user-human", name: "Human" },
429
+ issue: {
430
+ id: "issue-work-1",
431
+ identifier: "ENG-350",
432
+ title: "Login implementation",
433
+ team: { id: "team-1" },
434
+ project: null,
435
+ },
436
+ createdAt: new Date().toISOString(),
437
+ },
438
+ });
439
+ await postWebhook(api, payload);
440
+
441
+ await waitForMock(mockRunAgent);
442
+
443
+ expect(mockClassifyIntent).toHaveBeenCalledOnce();
444
+
445
+ const logs = infoLogs(api);
446
+ expect(logs.some((l) => l.includes("request_work"))).toBe(true);
447
+ expect(mockRunAgent).toHaveBeenCalledOnce();
448
+ });
449
+
450
+ it("bot's own comment: skips without running agent", async () => {
451
+ const api = createApi();
452
+ const payload = makeCommentCreateFromBot("viewer-bot-1");
453
+ await postWebhook(api, payload);
454
+ // Small wait for the async getViewerId check
455
+ await new Promise((r) => setTimeout(r, 100));
456
+
457
+ const logs = infoLogs(api);
458
+ expect(logs.some((l) => l.includes("skipping our own comment"))).toBe(true);
459
+ expect(mockRunAgent).not.toHaveBeenCalled();
460
+ expect(mockClassifyIntent).not.toHaveBeenCalled();
461
+ });
462
+
463
+ it("general intent: no action taken, no agent run", async () => {
464
+ const api = createApi();
465
+ const payload = makeCommentCreate({
466
+ data: {
467
+ id: "comment-general-1",
468
+ body: "Thanks for the update",
469
+ user: { id: "user-human", name: "Human" },
470
+ issue: {
471
+ id: "issue-general-1",
472
+ identifier: "ENG-302",
473
+ title: "General test",
474
+ team: { id: "team-1" },
475
+ project: null,
476
+ },
477
+ createdAt: new Date().toISOString(),
478
+ },
479
+ });
480
+ await postWebhook(api, payload);
481
+ await vi.waitFor(
482
+ () => { expect(mockClassifyIntent).toHaveBeenCalledOnce(); },
483
+ { timeout: 2000, interval: 50 },
484
+ );
485
+
486
+ const logs = infoLogs(api);
487
+ expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
488
+ expect(mockRunAgent).not.toHaveBeenCalled();
489
+ });
490
+ });
491
+
492
+ describe("Issue.update", () => {
493
+ it("assignment dispatch: triggers handleDispatch pipeline", async () => {
494
+ // Set viewerId to match the fixture's assigneeId
495
+ mockGetViewerId.mockResolvedValue("viewer-1");
496
+
497
+ const api = createApi();
498
+ const payload = makeIssueUpdateWithAssignment();
499
+ await postWebhook(api, payload);
500
+
501
+ await waitForMock(mockSpawnWorker, { timeout: 3000 });
502
+ expect(mockSpawnWorker).toHaveBeenCalledOnce();
503
+ });
504
+ });
505
+
506
+ describe("Issue.create", () => {
507
+ it("auto-triage: applies estimate, labels, priority from agent output", async () => {
508
+ // Mock getIssueDetails to return issue matching the payload
509
+ mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
510
+ id: "issue-new",
511
+ identifier: "ENG-200",
512
+ title: "New issue",
513
+ }));
514
+
515
+ mockRunAgent.mockResolvedValueOnce({
516
+ success: true,
517
+ output:
518
+ '```json\n{"estimate": 3, "labelIds": ["label-bug"], "priority": 3, "assessment": "Medium complexity"}\n```\n\nThis issue needs moderate work.',
519
+ });
520
+
521
+ const api = createApi();
522
+ const payload = makeIssueCreate();
523
+ await postWebhook(api, payload);
524
+
525
+ // Wait for the triage handler to complete
526
+ await waitForMock(mockClearActiveSession);
527
+
528
+ // Issue enrichment + team labels
529
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-new");
530
+ expect(mockGetTeamLabels).toHaveBeenCalled();
531
+
532
+ // Session created for triage
533
+ expect(mockCreateSessionOnIssue).toHaveBeenCalledWith("issue-new");
534
+
535
+ // Agent invoked in read-only mode
536
+ expect(mockRunAgent).toHaveBeenCalledOnce();
537
+ const runArgs = mockRunAgent.mock.calls[0][0];
538
+ expect(runArgs.readOnly).toBe(true);
539
+ expect(runArgs.message).toContain("ENG-200");
540
+
541
+ // Triage JSON applied to issue
542
+ expect(mockUpdateIssue).toHaveBeenCalledWith(
543
+ "issue-new",
544
+ expect.objectContaining({
545
+ estimate: 3,
546
+ priority: 3,
547
+ }),
548
+ );
549
+
550
+ // Response delivered via emitActivity (session exists)
551
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
552
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-new");
553
+ });
554
+ });
555
+
556
+ describe("AppUserNotification", () => {
557
+ it("ignored: returns 200 with no API calls", async () => {
558
+ const api = createApi();
559
+ const result = await postWebhook(api, makeAppUserNotification());
560
+ expect(result.status).toBe(200);
561
+
562
+ const logs = infoLogs(api);
563
+ expect(logs.some((l) => l.includes("AppUserNotification ignored"))).toBe(true);
564
+
565
+ expect(mockRunAgent).not.toHaveBeenCalled();
566
+ expect(mockCreateComment).not.toHaveBeenCalled();
567
+ expect(mockGetIssueDetails).not.toHaveBeenCalled();
568
+ });
569
+ });
570
+ });
@@ -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) => {