@calltelemetry/openclaw-linear 0.8.8 → 0.9.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/README.md +194 -91
- package/index.ts +36 -4
- package/package.json +1 -1
- package/src/__test__/webhook-scenarios.test.ts +1 -1
- package/src/gateway/dispatch-methods.test.ts +9 -9
- package/src/infra/commands.test.ts +5 -5
- package/src/infra/config-paths.test.ts +246 -0
- package/src/infra/doctor.ts +45 -36
- package/src/infra/notify.test.ts +49 -0
- package/src/infra/notify.ts +7 -2
- package/src/infra/observability.ts +1 -0
- package/src/infra/shared-profiles.test.ts +262 -0
- package/src/infra/shared-profiles.ts +116 -0
- package/src/infra/template.test.ts +86 -0
- package/src/infra/template.ts +18 -0
- package/src/infra/validation.test.ts +175 -0
- package/src/infra/validation.ts +52 -0
- package/src/pipeline/active-session.test.ts +2 -2
- package/src/pipeline/agent-end-hook.test.ts +305 -0
- package/src/pipeline/artifacts.test.ts +3 -3
- package/src/pipeline/dispatch-state.test.ts +111 -8
- package/src/pipeline/dispatch-state.ts +48 -13
- package/src/pipeline/e2e-dispatch.test.ts +2 -2
- package/src/pipeline/intent-classify.test.ts +20 -2
- package/src/pipeline/intent-classify.ts +14 -24
- package/src/pipeline/pipeline.ts +28 -11
- package/src/pipeline/planner.ts +1 -8
- package/src/pipeline/planning-state.ts +9 -0
- package/src/pipeline/tier-assess.test.ts +39 -39
- package/src/pipeline/tier-assess.ts +15 -33
- package/src/pipeline/webhook.test.ts +149 -1
- package/src/pipeline/webhook.ts +90 -62
- package/src/tools/dispatch-history-tool.test.ts +21 -20
- package/src/tools/dispatch-history-tool.ts +1 -1
- package/src/tools/linear-issues-tool.test.ts +115 -0
- package/src/tools/linear-issues-tool.ts +25 -0
|
@@ -45,6 +45,7 @@ vi.mock("../api/linear-api.js", () => ({
|
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
47
|
import { createLinearIssuesTool } from "./linear-issues-tool.js";
|
|
48
|
+
import { isValidIssueId as isValidLinearId } from "../infra/validation.js";
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
50
51
|
// Helpers
|
|
@@ -91,6 +92,55 @@ beforeEach(() => {
|
|
|
91
92
|
});
|
|
92
93
|
});
|
|
93
94
|
|
|
95
|
+
describe("isValidLinearId", () => {
|
|
96
|
+
it("accepts TEAM-123 format identifiers", () => {
|
|
97
|
+
expect(isValidLinearId("ENG-123")).toBe(true);
|
|
98
|
+
expect(isValidLinearId("API-1")).toBe(true);
|
|
99
|
+
expect(isValidLinearId("CT-42")).toBe(true);
|
|
100
|
+
expect(isValidLinearId("A-1")).toBe(true);
|
|
101
|
+
expect(isValidLinearId("Team2-999")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("accepts UUID format identifiers", () => {
|
|
105
|
+
expect(isValidLinearId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
|
106
|
+
expect(isValidLinearId("08cba264-d774-4afd-bc93-ee8213d12ef8")).toBe(true);
|
|
107
|
+
expect(isValidLinearId("ABCDEF01-2345-6789-ABCD-EF0123456789")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects empty strings", () => {
|
|
111
|
+
expect(isValidLinearId("")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects SQL injection attempts", () => {
|
|
115
|
+
expect(isValidLinearId("ENG-123'; DROP TABLE issues; --")).toBe(false);
|
|
116
|
+
expect(isValidLinearId("' OR 1=1 --")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects GraphQL injection attempts", () => {
|
|
120
|
+
expect(isValidLinearId('{ __schema { types { name } } }')).toBe(false);
|
|
121
|
+
expect(isValidLinearId("ENG-123\n{malicious}")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("rejects path traversal", () => {
|
|
125
|
+
expect(isValidLinearId("../../../etc/passwd")).toBe(false);
|
|
126
|
+
expect(isValidLinearId("..\\..\\..\\windows")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects strings that look like IDs but have extra characters", () => {
|
|
130
|
+
expect(isValidLinearId("ENG-123-extra")).toBe(false);
|
|
131
|
+
expect(isValidLinearId("-123")).toBe(false);
|
|
132
|
+
expect(isValidLinearId("123-ENG")).toBe(false);
|
|
133
|
+
expect(isValidLinearId("ENG-")).toBe(false);
|
|
134
|
+
expect(isValidLinearId("ENG")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("rejects UUIDs with wrong format", () => {
|
|
138
|
+
expect(isValidLinearId("550e8400-e29b-41d4-a716")).toBe(false);
|
|
139
|
+
expect(isValidLinearId("not-a-uuid-at-all-nope")).toBe(false);
|
|
140
|
+
expect(isValidLinearId("550e8400e29b41d4a716446655440000")).toBe(false); // missing dashes
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
94
144
|
describe("linear_issues tool", () => {
|
|
95
145
|
describe("read action", () => {
|
|
96
146
|
it("returns formatted issue details", async () => {
|
|
@@ -427,6 +477,71 @@ describe("linear_issues tool", () => {
|
|
|
427
477
|
});
|
|
428
478
|
});
|
|
429
479
|
|
|
480
|
+
describe("input validation", () => {
|
|
481
|
+
it("rejects invalid issueId format on read", async () => {
|
|
482
|
+
const result = parseResult(await executeTool({ action: "read", issueId: "'; DROP TABLE --" }));
|
|
483
|
+
expect(result.error).toMatch(/Invalid issueId format/);
|
|
484
|
+
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("rejects invalid issueId format on update", async () => {
|
|
488
|
+
const result = parseResult(await executeTool({ action: "update", issueId: "bad id!", status: "Done" }));
|
|
489
|
+
expect(result.error).toMatch(/Invalid issueId format/);
|
|
490
|
+
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("rejects invalid issueId format on comment", async () => {
|
|
494
|
+
const result = parseResult(await executeTool({ action: "comment", issueId: "{graphql}", body: "test" }));
|
|
495
|
+
expect(result.error).toMatch(/Invalid issueId format/);
|
|
496
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("rejects invalid teamId format on create", async () => {
|
|
500
|
+
const result = parseResult(await executeTool({ action: "create", title: "Test", teamId: "invalid team!" }));
|
|
501
|
+
expect(result.error).toMatch(/Invalid teamId format/);
|
|
502
|
+
expect(mockCreateIssue).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("rejects invalid parentIssueId format on create", async () => {
|
|
506
|
+
const result = parseResult(await executeTool({ action: "create", title: "Test", parentIssueId: "not/valid" }));
|
|
507
|
+
expect(result.error).toMatch(/Invalid parentIssueId format/);
|
|
508
|
+
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("rejects invalid projectId format on create", async () => {
|
|
512
|
+
const result = parseResult(await executeTool({ action: "create", title: "Test", teamId: "team-1", projectId: "bad project" }));
|
|
513
|
+
expect(result.error).toMatch(/Invalid projectId format/);
|
|
514
|
+
expect(mockCreateIssue).not.toHaveBeenCalled();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("rejects invalid teamId format on list_states", async () => {
|
|
518
|
+
const result = parseResult(await executeTool({ action: "list_states", teamId: "../../../etc/passwd" }));
|
|
519
|
+
expect(result.error).toMatch(/Invalid teamId format/);
|
|
520
|
+
expect(mockGetTeamStates).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("rejects invalid teamId format on list_labels", async () => {
|
|
524
|
+
const result = parseResult(await executeTool({ action: "list_labels", teamId: "' OR 1=1" }));
|
|
525
|
+
expect(result.error).toMatch(/Invalid teamId format/);
|
|
526
|
+
expect(mockGetTeamLabels).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("accepts valid TEAM-123 issueId", async () => {
|
|
530
|
+
mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
|
|
531
|
+
const result = parseResult(await executeTool({ action: "read", issueId: "ENG-123" }));
|
|
532
|
+
expect(result.error).toBeUndefined();
|
|
533
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith("ENG-123");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("accepts valid UUID issueId", async () => {
|
|
537
|
+
const uuid = "08cba264-d774-4afd-bc93-ee8213d12ef8";
|
|
538
|
+
mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
|
|
539
|
+
const result = parseResult(await executeTool({ action: "read", issueId: uuid }));
|
|
540
|
+
expect(result.error).toBeUndefined();
|
|
541
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith(uuid);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
430
545
|
describe("error handling", () => {
|
|
431
546
|
it("returns error when no token available", async () => {
|
|
432
547
|
mockResolveLinearToken.mockReturnValueOnce({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
3
3
|
import { LinearAgentApi, resolveLinearToken } from "../api/linear-api.js";
|
|
4
|
+
import { isValidIssueId as isValidLinearId } from "../infra/validation.js";
|
|
4
5
|
|
|
5
6
|
type Action = "read" | "create" | "update" | "comment" | "list_states" | "list_labels";
|
|
6
7
|
|
|
@@ -38,6 +39,9 @@ async function handleRead(api: LinearAgentApi, params: ToolParams) {
|
|
|
38
39
|
if (!params.issueId) {
|
|
39
40
|
return jsonResult({ error: "issueId is required for action='read'" });
|
|
40
41
|
}
|
|
42
|
+
if (!isValidLinearId(params.issueId)) {
|
|
43
|
+
return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
|
|
44
|
+
}
|
|
41
45
|
const issue = await api.getIssueDetails(params.issueId);
|
|
42
46
|
return jsonResult({
|
|
43
47
|
identifier: issue.identifier,
|
|
@@ -69,6 +73,15 @@ async function handleCreate(api: LinearAgentApi, params: ToolParams) {
|
|
|
69
73
|
if (!params.title) {
|
|
70
74
|
return jsonResult({ error: "title is required for action='create'" });
|
|
71
75
|
}
|
|
76
|
+
if (params.teamId && !isValidLinearId(params.teamId)) {
|
|
77
|
+
return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
|
|
78
|
+
}
|
|
79
|
+
if (params.parentIssueId && !isValidLinearId(params.parentIssueId)) {
|
|
80
|
+
return jsonResult({ error: `Invalid parentIssueId format: "${params.parentIssueId}". Expected TEAM-123 or UUID.` });
|
|
81
|
+
}
|
|
82
|
+
if (params.projectId && !isValidLinearId(params.projectId)) {
|
|
83
|
+
return jsonResult({ error: `Invalid projectId format: "${params.projectId}". Expected TEAM-123 or UUID.` });
|
|
84
|
+
}
|
|
72
85
|
|
|
73
86
|
// Resolve teamId: explicit param, or derive from parent issue
|
|
74
87
|
let teamId = params.teamId;
|
|
@@ -133,6 +146,9 @@ async function handleUpdate(api: LinearAgentApi, params: ToolParams) {
|
|
|
133
146
|
if (!params.issueId) {
|
|
134
147
|
return jsonResult({ error: "issueId is required for action='update'" });
|
|
135
148
|
}
|
|
149
|
+
if (!isValidLinearId(params.issueId)) {
|
|
150
|
+
return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
|
|
151
|
+
}
|
|
136
152
|
|
|
137
153
|
const hasFields = params.status || params.priority != null || params.estimate != null || params.labels || params.title;
|
|
138
154
|
if (!hasFields) {
|
|
@@ -209,6 +225,9 @@ async function handleComment(api: LinearAgentApi, params: ToolParams) {
|
|
|
209
225
|
if (!params.issueId) {
|
|
210
226
|
return jsonResult({ error: "issueId is required for action='comment'" });
|
|
211
227
|
}
|
|
228
|
+
if (!isValidLinearId(params.issueId)) {
|
|
229
|
+
return jsonResult({ error: `Invalid issueId format: "${params.issueId}". Expected TEAM-123 or UUID.` });
|
|
230
|
+
}
|
|
212
231
|
if (!params.body) {
|
|
213
232
|
return jsonResult({ error: "body is required for action='comment'" });
|
|
214
233
|
}
|
|
@@ -220,6 +239,9 @@ async function handleListStates(api: LinearAgentApi, params: ToolParams) {
|
|
|
220
239
|
if (!params.teamId) {
|
|
221
240
|
return jsonResult({ error: "teamId is required for action='list_states'" });
|
|
222
241
|
}
|
|
242
|
+
if (!isValidLinearId(params.teamId)) {
|
|
243
|
+
return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
|
|
244
|
+
}
|
|
223
245
|
const states = await api.getTeamStates(params.teamId);
|
|
224
246
|
return jsonResult({ states });
|
|
225
247
|
}
|
|
@@ -228,6 +250,9 @@ async function handleListLabels(api: LinearAgentApi, params: ToolParams) {
|
|
|
228
250
|
if (!params.teamId) {
|
|
229
251
|
return jsonResult({ error: "teamId is required for action='list_labels'" });
|
|
230
252
|
}
|
|
253
|
+
if (!isValidLinearId(params.teamId)) {
|
|
254
|
+
return jsonResult({ error: `Invalid teamId format: "${params.teamId}". Expected TEAM-123 or UUID.` });
|
|
255
|
+
}
|
|
231
256
|
const labels = await api.getTeamLabels(params.teamId);
|
|
232
257
|
return jsonResult({ labels });
|
|
233
258
|
}
|