@actagent/diffs 2026.6.2

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/src/url.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Diffs plugin module implements url behavior.
2
+ import type { ACTAgentConfig } from "../api.js";
3
+
4
+ const DEFAULT_GATEWAY_PORT = 18789;
5
+ type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
6
+
7
+ export function buildViewerUrl(params: {
8
+ config: ACTAgentConfig;
9
+ viewerPath: string;
10
+ baseUrl?: string;
11
+ }): string {
12
+ const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
13
+ const normalizedBase = normalizeViewerBaseUrl(baseUrl);
14
+ const viewerPath = params.viewerPath.startsWith("/")
15
+ ? params.viewerPath
16
+ : `/${params.viewerPath}`;
17
+ const parsedBase = new URL(normalizedBase);
18
+ const basePath = parsedBase.pathname === "/" ? "" : parsedBase.pathname.replace(/\/+$/, "");
19
+ parsedBase.pathname = `${basePath}${viewerPath}`;
20
+ parsedBase.search = "";
21
+ parsedBase.hash = "";
22
+ return parsedBase.toString();
23
+ }
24
+
25
+ export function normalizeViewerBaseUrl(
26
+ raw: string,
27
+ fieldName: ViewerBaseUrlFieldName = "baseUrl",
28
+ ): string {
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(raw);
32
+ } catch {
33
+ throw new Error(`Invalid ${fieldName}: ${raw}`);
34
+ }
35
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
36
+ throw new Error(`${fieldName} must use http or https: ${raw}`);
37
+ }
38
+ if (parsed.search || parsed.hash) {
39
+ throw new Error(`${fieldName} must not include query/hash: ${raw}`);
40
+ }
41
+ parsed.search = "";
42
+ parsed.hash = "";
43
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
44
+ const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
45
+ return withoutTrailingSlash;
46
+ }
47
+
48
+ function resolveGatewayBaseUrl(config: ACTAgentConfig): string {
49
+ const scheme = config.gateway?.tls?.enabled ? "https" : "http";
50
+ const port =
51
+ typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
52
+ const customHost = config.gateway?.customBindHost?.trim();
53
+
54
+ if (config.gateway?.bind === "custom" && customHost) {
55
+ return `${scheme}://${customHost}:${port}`;
56
+ }
57
+
58
+ // Viewer links are used by local canvas/clients; default to loopback to avoid
59
+ // container/bridge interfaces that are often unreachable from the caller.
60
+ return `${scheme}://127.0.0.1:${port}`;
61
+ }
@@ -0,0 +1,190 @@
1
+ // Diffs plugin module implements viewer assets behavior.
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
7
+ export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
8
+ export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
9
+ export const LANGUAGE_PACK_VIEWER_ASSET_PREFIX = "/plugins/diffs-language-pack/assets/";
10
+ export const LANGUAGE_PACK_VIEWER_LOADER_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer.js`;
11
+ export const LANGUAGE_PACK_VIEWER_RUNTIME_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer-runtime.js`;
12
+ const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js";
13
+ const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
14
+ "./assets/viewer-runtime.js",
15
+ "../assets/viewer-runtime.js",
16
+ ] as const;
17
+ const LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
18
+ "../../diffs-language-pack/assets/viewer-runtime.js",
19
+ "../diffs-language-pack/assets/viewer-runtime.js",
20
+ ] as const;
21
+
22
+ type ServedViewerAsset = {
23
+ body: string | Buffer;
24
+ contentType: string;
25
+ };
26
+
27
+ type RuntimeAssetCache = {
28
+ mtimeMs: number;
29
+ runtimeBody: Buffer;
30
+ loaderBody: string;
31
+ };
32
+
33
+ let runtimeAssetCache: RuntimeAssetCache | null = null;
34
+ let languagePackRuntimeAssetCache: RuntimeAssetCache | null = null;
35
+
36
+ type ViewerRuntimeFileUrlParams = {
37
+ baseUrl?: string | URL;
38
+ stat?: (path: string) => Promise<unknown>;
39
+ };
40
+
41
+ function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
42
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
43
+ }
44
+
45
+ export async function resolveViewerRuntimeFileUrl(
46
+ params: ViewerRuntimeFileUrlParams = {},
47
+ ): Promise<URL> {
48
+ const baseUrl = params.baseUrl ?? import.meta.url;
49
+ const stat = params.stat ?? ((path: string) => fs.stat(path));
50
+ let missingFileError: NodeJS.ErrnoException | null = null;
51
+
52
+ for (const relativePath of VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS) {
53
+ const candidateUrl = new URL(relativePath, baseUrl);
54
+ try {
55
+ await stat(fileURLToPath(candidateUrl));
56
+ return candidateUrl;
57
+ } catch (error) {
58
+ if (isMissingFileError(error)) {
59
+ missingFileError = error;
60
+ continue;
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ if (missingFileError) {
67
+ throw missingFileError;
68
+ }
69
+
70
+ throw new Error("viewer runtime asset candidates were not checked");
71
+ }
72
+
73
+ export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
74
+ if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
75
+ return null;
76
+ }
77
+
78
+ const assets = await loadViewerAssets();
79
+ if (pathname === VIEWER_LOADER_PATH) {
80
+ return {
81
+ body: assets.loaderBody,
82
+ contentType: "text/javascript; charset=utf-8",
83
+ };
84
+ }
85
+
86
+ if (pathname === VIEWER_RUNTIME_PATH) {
87
+ return {
88
+ body: assets.runtimeBody,
89
+ contentType: "text/javascript; charset=utf-8",
90
+ };
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ export async function getServedLanguagePackViewerAsset(
97
+ pathname: string,
98
+ ): Promise<ServedViewerAsset | null> {
99
+ if (
100
+ pathname !== LANGUAGE_PACK_VIEWER_LOADER_PATH &&
101
+ pathname !== LANGUAGE_PACK_VIEWER_RUNTIME_PATH
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ let assets: RuntimeAssetCache;
107
+ try {
108
+ const runtimeUrl = await resolveRuntimeFileUrl(LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS);
109
+ assets = await loadRuntimeAssets({
110
+ runtimeUrl,
111
+ cache: languagePackRuntimeAssetCache,
112
+ updateCache: (cache) => {
113
+ languagePackRuntimeAssetCache = cache;
114
+ },
115
+ });
116
+ } catch (error) {
117
+ if (isMissingFileError(error)) {
118
+ return null;
119
+ }
120
+ throw error;
121
+ }
122
+ if (pathname === LANGUAGE_PACK_VIEWER_LOADER_PATH) {
123
+ return {
124
+ body: assets.loaderBody,
125
+ contentType: "text/javascript; charset=utf-8",
126
+ };
127
+ }
128
+
129
+ return {
130
+ body: assets.runtimeBody,
131
+ contentType: "text/javascript; charset=utf-8",
132
+ };
133
+ }
134
+
135
+ async function loadViewerAssets(): Promise<RuntimeAssetCache> {
136
+ const runtimeUrl = await resolveViewerRuntimeFileUrl();
137
+ return loadRuntimeAssets({
138
+ runtimeUrl,
139
+ cache: runtimeAssetCache,
140
+ updateCache: (cache) => {
141
+ runtimeAssetCache = cache;
142
+ },
143
+ });
144
+ }
145
+
146
+ async function loadRuntimeAssets(params: {
147
+ cache: RuntimeAssetCache | null;
148
+ runtimeUrl: URL;
149
+ updateCache(cache: RuntimeAssetCache): void;
150
+ }): Promise<RuntimeAssetCache> {
151
+ const runtimePath = fileURLToPath(params.runtimeUrl);
152
+ const runtimeStat = await fs.stat(runtimePath);
153
+ if (params.cache && params.cache.mtimeMs === runtimeStat.mtimeMs) {
154
+ return params.cache;
155
+ }
156
+
157
+ const runtimeBody = await fs.readFile(runtimePath);
158
+ const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
159
+ const cache = {
160
+ mtimeMs: runtimeStat.mtimeMs,
161
+ runtimeBody,
162
+ loaderBody: `import "${VIEWER_RUNTIME_RELATIVE_IMPORT_PATH}?v=${hash}";\n`,
163
+ };
164
+ params.updateCache(cache);
165
+ return cache;
166
+ }
167
+
168
+ async function resolveRuntimeFileUrl(relativePaths: readonly string[]): Promise<URL> {
169
+ let missingFileError: NodeJS.ErrnoException | null = null;
170
+
171
+ for (const relativePath of relativePaths) {
172
+ const candidateUrl = new URL(relativePath, import.meta.url);
173
+ try {
174
+ await fs.stat(fileURLToPath(candidateUrl));
175
+ return candidateUrl;
176
+ } catch (error) {
177
+ if (isMissingFileError(error)) {
178
+ missingFileError = error;
179
+ continue;
180
+ }
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ if (missingFileError) {
186
+ throw missingFileError;
187
+ }
188
+
189
+ throw new Error("viewer runtime asset candidates were not checked");
190
+ }
@@ -0,0 +1,175 @@
1
+ /* @vitest-environment jsdom */
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const disableAutoStartKey = Symbol.for("actagent.diffs.disableAutoStart");
8
+ (globalThis as typeof globalThis & Record<symbol, unknown>)[disableAutoStartKey] = true;
9
+
10
+ const VIEWER_CLIENT_SRC = readFileSync(
11
+ path.join(process.cwd(), "extensions/diffs/src/viewer-client.ts"),
12
+ "utf8",
13
+ );
14
+
15
+ const XSS_PATTERNS = ["onerror", "<script", "onclick", "javascript:", "onload"];
16
+
17
+ const {
18
+ fileDiffHydrateMock,
19
+ fileDiffRerenderMock,
20
+ fileDiffSetOptionsMock,
21
+ preloadHighlighterMock,
22
+ } = vi.hoisted(() => ({
23
+ fileDiffHydrateMock: vi.fn(),
24
+ fileDiffRerenderMock: vi.fn(),
25
+ fileDiffSetOptionsMock: vi.fn(),
26
+ preloadHighlighterMock: vi.fn(async () => undefined),
27
+ }));
28
+
29
+ vi.mock("@pierre/diffs", () => ({
30
+ FileDiff: class {
31
+ hydrate(params: unknown) {
32
+ return fileDiffHydrateMock(params);
33
+ }
34
+ rerender() {
35
+ return fileDiffRerenderMock();
36
+ }
37
+ setOptions(params: unknown) {
38
+ return fileDiffSetOptionsMock(params);
39
+ }
40
+ },
41
+ preloadHighlighter: preloadHighlighterMock,
42
+ }));
43
+
44
+ const viewerPayload = JSON.stringify({
45
+ prerenderedHTML: "<div>diff</div>",
46
+ options: {
47
+ theme: { light: "pierre-light", dark: "pierre-dark" },
48
+ diffStyle: "unified",
49
+ diffIndicators: "bars",
50
+ disableLineNumbers: false,
51
+ expandUnchanged: false,
52
+ themeType: "dark",
53
+ backgroundEnabled: true,
54
+ overflow: "wrap",
55
+ unsafeCSS: "",
56
+ },
57
+ langs: ["text"],
58
+ oldFile: { fileName: "a.ts", lang: "text", content: "old" },
59
+ newFile: { fileName: "a.ts", lang: "text", content: "new" },
60
+ });
61
+
62
+ function renderCard(): void {
63
+ document.body.insertAdjacentHTML(
64
+ "beforeend",
65
+ `<section class="oc-diff-card">
66
+ <div data-actagent-diff-host></div>
67
+ <script type="application/json" data-actagent-diff-payload>${viewerPayload}</script>
68
+ </section>`,
69
+ );
70
+ }
71
+
72
+ describe("createToolbarButton icon safety", () => {
73
+ it("toolbarIconSvg map exists and has exactly 8 icon names", () => {
74
+ const requiredNames = [
75
+ "split",
76
+ "unified",
77
+ "wrap-on",
78
+ "wrap-off",
79
+ "background-on",
80
+ "background-off",
81
+ "theme-dark",
82
+ "theme-light",
83
+ ] as const;
84
+ for (const name of requiredNames) {
85
+ expect(
86
+ VIEWER_CLIENT_SRC.includes(name + ":") || VIEWER_CLIENT_SRC.includes(`"${name}"`),
87
+ `icon "${name}" should exist in toolbarIconSvg`,
88
+ ).toBe(true);
89
+ }
90
+ });
91
+
92
+ it("no iconMarkup: string parameter exists", () => {
93
+ expect(VIEWER_CLIENT_SRC.includes("iconMarkup: string")).toBe(false);
94
+ });
95
+
96
+ it("innerHTML reads only from toolbarIconSvg lookup", () => {
97
+ expect(VIEWER_CLIENT_SRC.includes("button.innerHTML = toolbarIconSvg[params.icon]")).toBe(true);
98
+ });
99
+
100
+ it("SVG strings in toolbarIconSvg contain no XSS patterns", () => {
101
+ for (const pattern of XSS_PATTERNS) {
102
+ expect(
103
+ VIEWER_CLIENT_SRC.includes(pattern),
104
+ `source must not contain "${pattern}"`,
105
+ ).toBe(false);
106
+ }
107
+ });
108
+
109
+ it("old icon functions are removed", () => {
110
+ const removedFunctions = [
111
+ "function splitIcon(",
112
+ "function unifiedIcon(",
113
+ "function wrapIcon(",
114
+ "function backgroundIcon(",
115
+ "function themeIcon(",
116
+ ];
117
+ for (const fn of removedFunctions) {
118
+ expect(VIEWER_CLIENT_SRC.includes(fn), `"${fn}" should be removed`).toBe(false);
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("hydrateViewer", () => {
124
+ beforeEach(() => {
125
+ document.body.innerHTML = "";
126
+ delete document.documentElement.dataset.actagentDiffsError;
127
+ delete document.documentElement.dataset.actagentDiffsReady;
128
+ vi.clearAllMocks();
129
+ });
130
+
131
+ it("continues hydrating later cards when one card throws", async () => {
132
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
133
+ renderCard();
134
+ renderCard();
135
+ fileDiffHydrateMock.mockImplementationOnce(() => {
136
+ throw new Error("broken card");
137
+ });
138
+ const { controllers, hydrateViewer } = await import("./viewer-client.js");
139
+ controllers.splice(0);
140
+
141
+ await hydrateViewer();
142
+
143
+ expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
144
+ expect(controllers).toHaveLength(1);
145
+ expect(warn).toHaveBeenCalledWith(
146
+ "Skipping diff card that failed to hydrate",
147
+ expect.any(Error),
148
+ );
149
+ expect(document.documentElement.dataset.actagentDiffsError).toBeUndefined();
150
+ warn.mockRestore();
151
+ });
152
+
153
+ it("does not retain controllers when initial state application throws", async () => {
154
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
155
+ renderCard();
156
+ renderCard();
157
+ fileDiffSetOptionsMock.mockImplementationOnce(() => {
158
+ throw new Error("broken options");
159
+ });
160
+ const { controllers, hydrateViewer } = await import("./viewer-client.js");
161
+ controllers.splice(0);
162
+
163
+ await hydrateViewer();
164
+
165
+ expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
166
+ expect(fileDiffSetOptionsMock).toHaveBeenCalledTimes(2);
167
+ expect(controllers).toHaveLength(1);
168
+ expect(warn).toHaveBeenCalledWith(
169
+ "Skipping diff card that failed to hydrate",
170
+ expect.any(Error),
171
+ );
172
+ expect(document.documentElement.dataset.actagentDiffsError).toBeUndefined();
173
+ warn.mockRestore();
174
+ });
175
+ });