| 1 | import assert from "node:assert/strict"; |
| 2 | import * as fs from "node:fs"; |
| 3 | import * as os from "node:os"; |
| 4 | import * as path from "node:path"; |
| 5 | import { afterEach, beforeEach, describe, it } from "node:test"; |
| 6 | import { |
| 7 | extractFilePath, |
| 8 | isInsideRoot, |
| 9 | isRealPathInsideRoot, |
| 10 | projectItem, |
| 11 | statusForEvent, |
| 12 | } from "../transcript"; |
| 13 | import type { ItemRecord } from "../api"; |
| 14 | |
| 15 | const agent = (over: Partial<ItemRecord> = {}): ItemRecord => ({ |
| 16 | id: "i1", |
| 17 | kind: "agent_message", |
| 18 | summary: "", |
| 19 | ...over, |
| 20 | }); |
| 21 | |
| 22 | describe("projectItem", () => { |
| 23 | it("renders the full detail body, not the truncated summary", () => { |
| 24 | const stub = "Here is the plan" + "…".repeat(10); |
| 25 | const view = projectItem( |
| 26 | agent({ status: "completed", summary: stub, detail: "Here is the plan, in full, with steps." }), |
| 27 | undefined, |
| 28 | ); |
| 29 | assert.equal(view.summary, "Here is the plan, in full, with steps."); |
| 30 | assert.ok(view.html?.includes("in full")); |
| 31 | assert.ok(!view.html?.includes("…")); |
| 32 | }); |
| 33 | |
| 34 | it("falls back to summary when detail is absent on the wire", () => { |
| 35 | const view = projectItem(agent({ status: "completed", summary: "short reply" }), undefined); |
| 36 | assert.equal(view.summary, "short reply"); |
| 37 | assert.ok(view.html?.includes("short reply")); |
| 38 | }); |
| 39 | |
| 40 | it("keeps streamed text when a completion payload carries neither field", () => { |
| 41 | const streaming = projectItem(agent({ status: "in_progress" }), { |
| 42 | id: "i1", |
| 43 | kind: "agent_message", |
| 44 | summary: "partial", |
| 45 | streamText: "partial", |
| 46 | rev: 1, |
| 47 | }); |
| 48 | assert.equal(streaming.streamText, "partial"); |
| 49 | const done = projectItem(agent({ status: "completed" }), streaming); |
| 50 | assert.equal(done.summary, "partial"); |
| 51 | assert.equal(done.rev, streaming.rev + 1); |
| 52 | }); |
| 53 | |
| 54 | it("carries a parsed file path onto non-agent items", () => { |
| 55 | const view = projectItem( |
| 56 | { |
| 57 | id: "t1", |
| 58 | kind: "file_change", |
| 59 | summary: "Edited a file", |
| 60 | metadata: { tool_input: JSON.stringify({ file_path: "src/chat.ts" }) }, |
| 61 | }, |
| 62 | undefined, |
| 63 | ); |
| 64 | assert.equal(view.filePath, "src/chat.ts"); |
| 65 | }); |
| 66 | |
| 67 | it("merges detail and metadata forward from the existing view", () => { |
| 68 | const first = projectItem( |
| 69 | { id: "t1", kind: "tool_call", summary: "run", detail: "line one", metadata: { path: "a.ts" } }, |
| 70 | undefined, |
| 71 | ); |
| 72 | const second = projectItem({ id: "t1", kind: "tool_call", summary: "run" }, first, "item.completed"); |
| 73 | assert.equal(second.detail, "line one"); |
| 74 | assert.equal(second.filePath, "a.ts"); |
| 75 | }); |
| 76 | }); |
| 77 | |
| 78 | describe("extractFilePath", () => { |
| 79 | it("reads the path out of a JSON tool_input string", () => { |
| 80 | assert.equal( |
| 81 | extractFilePath({ tool_input: '{"file_path":"/repo/src/main.rs","content":"x"}' }), |
| 82 | "/repo/src/main.rs", |
| 83 | ); |
| 84 | }); |
| 85 | |
| 86 | it("reads an already-parsed tool_input object", () => { |
| 87 | assert.equal(extractFilePath({ tool_input: { path: "docs/README.md" } }), "docs/README.md"); |
| 88 | }); |
| 89 | |
| 90 | it("prefers a direct metadata path when present", () => { |
| 91 | assert.equal(extractFilePath({ path: "direct.ts", tool_input: '{"file_path":"other.ts"}' }), "direct.ts"); |
| 92 | }); |
| 93 | |
| 94 | it("survives malformed or missing tool_input", () => { |
| 95 | assert.equal(extractFilePath(undefined), undefined); |
| 96 | assert.equal(extractFilePath({}), undefined); |
| 97 | assert.equal(extractFilePath({ tool_input: "not json {" }), undefined); |
| 98 | assert.equal(extractFilePath({ tool_input: "[]" }), undefined); |
| 99 | assert.equal(extractFilePath({ tool_input: '{"command":"ls"}' }), undefined); |
| 100 | assert.equal(extractFilePath({ tool_input: '{"file_path":42}' }), undefined); |
| 101 | }); |
| 102 | |
| 103 | it("refuses paths carrying control characters", () => { |
| 104 | assert.equal(extractFilePath({ path: "src/\u0000etc/passwd" }), undefined); |
| 105 | assert.equal(extractFilePath({ tool_input: '{"file_path":"a\\u001bb.ts"}' }), undefined); |
| 106 | }); |
| 107 | }); |
| 108 | |
| 109 | describe("isInsideRoot", () => { |
| 110 | const root = path.resolve("/repo/project"); |
| 111 | |
| 112 | it("accepts a workspace-relative path", () => { |
| 113 | assert.equal(isInsideRoot(root, "src/chat.ts"), true); |
| 114 | assert.equal(isInsideRoot(root, path.join(root, "src", "chat.ts")), true); |
| 115 | }); |
| 116 | |
| 117 | it("refuses traversal and outside-workspace paths", () => { |
| 118 | assert.equal(isInsideRoot(root, "../secrets.env"), false); |
| 119 | assert.equal(isInsideRoot(root, "src/../../secrets.env"), false); |
| 120 | assert.equal(isInsideRoot(root, path.resolve("/etc/passwd")), false); |
| 121 | assert.equal(isInsideRoot(root, root), false); |
| 122 | assert.equal(isInsideRoot("", "src/chat.ts"), false); |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | describe("isInsideRoot dot-prefixed names", () => { |
| 127 | it("accepts a file whose name merely starts with two dots", () => { |
| 128 | const root = path.resolve("/repo/project"); |
| 129 | assert.equal(isInsideRoot(root, "..notes.md"), true); |
| 130 | assert.equal(isInsideRoot(root, "..hidden/file.ts"), true); |
| 131 | assert.equal(isInsideRoot(root, "../project-sibling/file.ts"), false); |
| 132 | }); |
| 133 | }); |
| 134 | |
| 135 | describe("isRealPathInsideRoot", () => { |
| 136 | let tmp: string; |
| 137 | let root: string; |
| 138 | let outside: string; |
| 139 | |
| 140 | beforeEach(() => { |
| 141 | // realpath: on macOS the temp dir itself sits behind /var -> /private/var. |
| 142 | tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cw-vscode-root-"))); |
| 143 | root = path.join(tmp, "workspace"); |
| 144 | outside = path.join(tmp, "outside"); |
| 145 | fs.mkdirSync(path.join(root, "src"), { recursive: true }); |
| 146 | fs.mkdirSync(outside); |
| 147 | fs.writeFileSync(path.join(root, "src", "inside.ts"), "x"); |
| 148 | fs.writeFileSync(path.join(outside, "secret.env"), "x"); |
| 149 | }); |
| 150 | |
| 151 | afterEach(() => { |
| 152 | fs.rmSync(tmp, { recursive: true, force: true }); |
| 153 | }); |
| 154 | |
| 155 | it("accepts real files, not-yet-existing files and a root reached through a link", async () => { |
| 156 | assert.equal(await isRealPathInsideRoot(root, "src/inside.ts"), true); |
| 157 | assert.equal(await isRealPathInsideRoot(root, "src/not-created-yet.ts"), true); |
| 158 | assert.equal(await isRealPathInsideRoot(root, path.join(root, "src", "inside.ts")), true); |
| 159 | const viaLink = path.join(tmp, "workspace-link"); |
| 160 | fs.symlinkSync(root, viaLink); |
| 161 | assert.equal(await isRealPathInsideRoot(viaLink, "src/inside.ts"), true); |
| 162 | }); |
| 163 | |
| 164 | it("refuses a link inside the workspace that points outside it", async () => { |
| 165 | fs.symlinkSync(path.join(outside, "secret.env"), path.join(root, "src", "linked.env")); |
| 166 | fs.symlinkSync(outside, path.join(root, "linked-dir")); |
| 167 | // Lexically inside, really outside: the lexical check alone is fooled. |
| 168 | assert.equal(isInsideRoot(root, "src/linked.env"), true); |
| 169 | assert.equal(await isRealPathInsideRoot(root, "src/linked.env"), false); |
| 170 | assert.equal(await isRealPathInsideRoot(root, "linked-dir/secret.env"), false); |
| 171 | assert.equal(await isRealPathInsideRoot(root, path.join(root, "linked-dir", "secret.env")), false); |
| 172 | // A file that does not exist yet behind a linked directory is outside too. |
| 173 | assert.equal(await isRealPathInsideRoot(root, "linked-dir/new.ts"), false); |
| 174 | }); |
| 175 | |
| 176 | it("refuses a dangling link and a link that stays inside the workspace is fine", async () => { |
| 177 | fs.symlinkSync(path.join(outside, "missing"), path.join(root, "dangling")); |
| 178 | assert.equal(await isRealPathInsideRoot(root, "dangling"), false); |
| 179 | fs.symlinkSync(path.join(root, "src", "inside.ts"), path.join(root, "alias.ts")); |
| 180 | assert.equal(await isRealPathInsideRoot(root, "alias.ts"), true); |
| 181 | }); |
| 182 | |
| 183 | it("still refuses traversal, the root itself and an unresolvable root", async () => { |
| 184 | assert.equal(await isRealPathInsideRoot(root, "../outside/secret.env"), false); |
| 185 | assert.equal(await isRealPathInsideRoot(root, root), false); |
| 186 | assert.equal(await isRealPathInsideRoot(path.join(tmp, "no-such-root"), "a.ts"), false); |
| 187 | assert.equal(await isRealPathInsideRoot("", "a.ts"), false); |
| 188 | }); |
| 189 | }); |
| 190 | |
| 191 | describe("statusForEvent", () => { |
| 192 | it("maps terminal events", () => { |
| 193 | assert.equal(statusForEvent("item.completed"), "completed"); |
| 194 | assert.equal(statusForEvent("item.failed"), "failed"); |
| 195 | assert.equal(statusForEvent("item.interrupted"), "interrupted"); |
| 196 | assert.equal(statusForEvent("item.canceled"), "interrupted"); |
| 197 | assert.equal(statusForEvent("item.started"), "in_progress"); |
| 198 | }); |
| 199 | }); |
| 200 |