返回 CodeWhale
transcript.ts
根目录 / extensions / vscode / src / transcript.ts
1 /**
2 * Transcript projection: turns runtime `ItemRecord`s (snapshot rows and SSE
3 * payloads) into the `ItemView` shape the chat webview renders.
4 *
5 * This is deliberately pure and free of `vscode` imports so it can be unit
6 * tested under plain `node --test`. Two behaviors live here because both are
7 * trust-boundary sensitive and were getting them wrong in the view layer:
8 *
9 * - `detail` vs `summary`: the runtime truncates `summary` to a stub and puts
10 * the real body in `detail`, which is `skip_serializing_if` on the wire and
11 * therefore legitimately absent. Prefer `detail`, fall back to `summary`.
12 * - file paths for the "Open file" action arrive inside `metadata.tool_input`
13 * (a JSON string), not as `metadata.path`. That value is model-influenced,
14 * so it is parsed defensively here and containment-checked before use.
15 */
16
17 import * as fs from "node:fs";
18 import * as path from "node:path";
19 import type { ItemRecord } from "./api";
20 import { renderMarkdown } from "./markdown";
21
22 export interface ItemView {
23 id: string;
24 kind: string;
25 status?: string;
26 turnId?: string;
27 summary: string;
28 detail?: string;
29 metadata?: Record<string, unknown>;
30 /** Workspace-relative or absolute path parsed out of tool metadata, if any. */
31 filePath?: string;
32 /** Rendered markdown for completed agent messages. */
33 html?: string;
34 codeBlocks?: string[];
35 /** In-progress agent text (plain, re-rendered on completion). */
36 streamText?: string;
37 rev: number;
38 }
39
40 /** Item status implied by an SSE event name. */
41 export function statusForEvent(event: string): string {
42 if (event === "item.completed") {
43 return "completed";
44 }
45 if (event === "item.failed") {
46 return "failed";
47 }
48 if (event === "item.interrupted" || event === "item.canceled") {
49 return "interrupted";
50 }
51 return "in_progress";
52 }
53
54 /**
55 * Build the view for one item, merging with whatever is already on screen so a
56 * partial SSE payload never blanks text that was already rendered.
57 */
58 export function projectItem(
59 item: ItemRecord,
60 existing: ItemView | undefined,
61 event?: string,
62 ): ItemView {
63 const isTerminal = event === "item.completed" || item.status === "completed";
64 const streamText = existing?.streamText;
65 const rev = (existing?.rev ?? 0) + 1;
66 const turnId = item.turnId ?? existing?.turnId;
67 const detail = item.detail ?? existing?.detail;
68 const metadata = item.metadata ?? existing?.metadata;
69
70 if (item.kind === "agent_message") {
71 // `detail` carries the full reply; `summary` is a 280-char stub on reload.
72 const text = detail || item.summary || streamText || existing?.summary || "";
73 if (!isTerminal) {
74 return {
75 id: item.id,
76 kind: item.kind,
77 status: item.status,
78 turnId,
79 summary: text,
80 detail,
81 streamText: text,
82 rev,
83 };
84 }
85 const rendered = renderMarkdown(text);
86 return {
87 id: item.id,
88 kind: item.kind,
89 status: item.status,
90 turnId,
91 summary: text,
92 detail,
93 html: rendered.html,
94 codeBlocks: rendered.codeBlocks,
95 rev,
96 };
97 }
98
99 return {
100 id: item.id,
101 kind: item.kind,
102 status: item.status,
103 turnId,
104 summary: item.summary || existing?.summary || "",
105 detail,
106 metadata,
107 filePath: extractFilePath(metadata),
108 rev,
109 };
110 }
111
112 const PATH_KEYS = ["path", "file_path", "filePath", "file", "notebook_path", "target_file"];
113
114 function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
115 for (const key of keys) {
116 const value = record[key];
117 if (typeof value === "string" && value.trim() !== "") {
118 return value.trim();
119 }
120 }
121 return undefined;
122 }
123
124 /**
125 * Pull a file path out of item metadata. The runtime puts tool arguments in
126 * `metadata.tool_input` as a JSON string, so a direct `metadata.path` lookup
127 * finds nothing for the file-change items that most want an Open button.
128 * Everything here is untrusted model output: parse failures are swallowed and
129 * callers must still validate the result before opening it.
130 */
131 export function extractFilePath(metadata: Record<string, unknown> | undefined): string | undefined {
132 if (!metadata) {
133 return undefined;
134 }
135 const direct = firstStringField(metadata, PATH_KEYS);
136 if (direct) {
137 return sanitizePath(direct);
138 }
139 const raw = metadata.tool_input ?? metadata.toolInput ?? metadata.input ?? metadata.arguments;
140 let parsed: unknown = raw;
141 if (typeof raw === "string") {
142 try {
143 parsed = JSON.parse(raw) as unknown;
144 } catch {
145 return undefined; // not JSON; nothing safe to offer
146 }
147 }
148 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
149 return undefined;
150 }
151 const nested = firstStringField(parsed as Record<string, unknown>, PATH_KEYS);
152 return nested ? sanitizePath(nested) : undefined;
153 }
154
155 /** Reject paths carrying control characters or NULs before they reach the FS. */
156 function sanitizePath(value: string): string | undefined {
157 return /[\u0000-\u001f\u007f]/.test(value) ? undefined : value;
158 }
159
160 /**
161 * True when `candidate` resolves strictly inside `root`. Used to keep a
162 * model-supplied path from escaping the workspace via `..` or an absolute
163 * path somewhere else on disk.
164 *
165 * This check is lexical: it does not look at the disk, so it cannot see a
166 * symbolic link. Anything that is about to be opened must also pass
167 * {@link isRealPathInsideRoot}.
168 */
169 export function isInsideRoot(root: string, candidate: string): boolean {
170 if (!root) {
171 return false;
172 }
173 const rootAbs = path.resolve(root);
174 // Relative candidates resolve against the root, not the process cwd.
175 const relative = path.relative(rootAbs, path.resolve(rootAbs, candidate));
176 return (
177 relative !== "" &&
178 relative !== ".." &&
179 !relative.startsWith(`..${path.sep}`) &&
180 !path.isAbsolute(relative)
181 );
182 }
183
184 /**
185 * The real path of `target`, following every link, even when the last
186 * components do not exist yet: the deepest existing ancestor is resolved and
187 * the missing tail is appended. A dangling link is refused (`undefined`)
188 * because its destination is unknown.
189 */
190 async function realPathAllowingMissingTail(target: string): Promise<string | undefined> {
191 const missing: string[] = [];
192 let current = target;
193 for (;;) {
194 try {
195 const real = await fs.promises.realpath(current);
196 return missing.length === 0 ? real : path.join(real, ...missing.reverse());
197 } catch (error) {
198 const code = (error as NodeJS.ErrnoException).code;
199 if (code !== "ENOENT" && code !== "ENOTDIR") {
200 return undefined;
201 }
202 }
203 try {
204 if ((await fs.promises.lstat(current)).isSymbolicLink()) {
205 return undefined;
206 }
207 } catch {
208 // Nothing is there: keep climbing.
209 }
210 const parent = path.dirname(current);
211 if (parent === current) {
212 return undefined;
213 }
214 missing.push(path.basename(current));
215 current = parent;
216 }
217 }
218
219 /**
220 * True when `candidate` is strictly inside `root` after links are resolved on
221 * both sides. A link inside the workspace that points elsewhere is therefore
222 * outside, which a lexical check cannot tell. Fails closed: a root or target
223 * that cannot be resolved is not inside.
224 */
225 export async function isRealPathInsideRoot(root: string, candidate: string): Promise<boolean> {
226 if (!isInsideRoot(root, candidate)) {
227 return false;
228 }
229 const rootAbs = path.resolve(root);
230 let realRoot: string;
231 try {
232 realRoot = await fs.promises.realpath(rootAbs);
233 } catch {
234 return false;
235 }
236 const realTarget = await realPathAllowingMissingTail(path.resolve(rootAbs, candidate));
237 return realTarget !== undefined && isInsideRoot(realRoot, realTarget);
238 }
239
239 lines TYPESCRIPT