@firstpick/pi-package-webui 0.3.6 → 0.3.8
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 +2 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +22 -22
- package/bin/pi-webui.mjs +452 -110
- package/images/WebUI_v0.3.7.png +0 -0
- package/index.ts +15 -4
- package/lib/auth-actions.mjs +81 -0
- package/lib/native-command-adapter.mjs +220 -0
- package/lib/session-actions.mjs +134 -0
- package/lib/temp-artifacts.mjs +34 -0
- package/lib/trust-boundaries.mjs +141 -0
- package/package.json +8 -4
- package/public/app.js +1278 -99
- package/public/index.html +22 -4
- package/public/service-worker.js +23 -9
- package/public/styles.css +454 -0
- package/start-webui.sh +6 -5
- package/tests/fixtures/fake-pi.mjs +73 -0
- package/tests/http-endpoints-harness.test.mjs +146 -0
- package/tests/mobile-static.test.mjs +66 -21
- package/tests/native-parity-harness.test.mjs +147 -0
- package/tests/native-parity.test.mjs +25 -6
- package/tests/run-all.mjs +19 -0
- package/tests/session-auth-harness.test.mjs +140 -0
- package/tests/temp-artifacts-harness.test.mjs +38 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
assertNativeCommandTrust,
|
|
7
|
+
evaluateDispatchTrustGuards,
|
|
8
|
+
evaluateTrustGuards,
|
|
9
|
+
guardsForNativeCommand,
|
|
10
|
+
isLocalAddress,
|
|
11
|
+
isLocalRequest,
|
|
12
|
+
LOCALHOST_ONLY_POST_ROUTES,
|
|
13
|
+
remoteShellTrustWarning,
|
|
14
|
+
requireLocalhost,
|
|
15
|
+
requireLocalhostRoute,
|
|
16
|
+
TRUST_GUARD_TYPES,
|
|
17
|
+
} from "../lib/trust-boundaries.mjs";
|
|
18
|
+
import {
|
|
19
|
+
nativeCommandBlocked,
|
|
20
|
+
nativeCommandResponse,
|
|
21
|
+
nativeCommandUnavailable,
|
|
22
|
+
nativeParitySurfaceForCommand,
|
|
23
|
+
nativeSlashCommandEntries,
|
|
24
|
+
NATIVE_COMMAND_STATUSES,
|
|
25
|
+
NATIVE_REFRESH_TARGETS,
|
|
26
|
+
parseSlashCommand,
|
|
27
|
+
rpcSuccess,
|
|
28
|
+
} from "../lib/native-command-adapter.mjs";
|
|
29
|
+
|
|
30
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
const parity = JSON.parse(await readFile(join(root, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
32
|
+
|
|
33
|
+
const localReq = { socket: { remoteAddress: "127.0.0.1" } };
|
|
34
|
+
const remoteReq = { socket: { remoteAddress: "192.168.1.50" } };
|
|
35
|
+
|
|
36
|
+
assert.equal(isLocalAddress("127.0.0.1"), true);
|
|
37
|
+
assert.equal(isLocalAddress("::ffff:127.0.0.1"), true);
|
|
38
|
+
assert.equal(isLocalAddress("::1"), true);
|
|
39
|
+
assert.equal(isLocalAddress("192.168.1.50"), false);
|
|
40
|
+
assert.equal(isLocalRequest(localReq), true);
|
|
41
|
+
assert.equal(isLocalRequest(remoteReq), false);
|
|
42
|
+
|
|
43
|
+
assert.throws(() => requireLocalhost(remoteReq, "blocked"), (error) => error.statusCode === 403);
|
|
44
|
+
assert.throws(() => requireLocalhostRoute(remoteReq, "/api/update"), (error) => error.statusCode === 403);
|
|
45
|
+
assert.doesNotThrow(() => requireLocalhostRoute(localReq, "/api/update"));
|
|
46
|
+
|
|
47
|
+
for (const pathname of LOCALHOST_ONLY_POST_ROUTES.keys()) {
|
|
48
|
+
assert.match(pathname, /^\/api\//, `${pathname} should be an API route`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const guard of parity.guardTaxonomy) {
|
|
52
|
+
assert.ok(TRUST_GUARD_TYPES.has(guard), `trust-boundaries should know parity guard ${guard}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const exportGuards = guardsForNativeCommand("export", parity);
|
|
56
|
+
assert.ok(exportGuards.includes("localhost"), "export should declare localhost guard in parity matrix");
|
|
57
|
+
|
|
58
|
+
const exportDispatchLocal = evaluateDispatchTrustGuards(exportGuards, { isLocal: true, confirmed: false });
|
|
59
|
+
const exportDispatchRemote = evaluateDispatchTrustGuards(exportGuards, { isLocal: false, confirmed: false, networkOpen: true });
|
|
60
|
+
assert.equal(exportDispatchLocal.allowed, true);
|
|
61
|
+
assert.equal(exportDispatchRemote.allowed, false);
|
|
62
|
+
assert.ok(exportDispatchRemote.blocked.includes("localhost"));
|
|
63
|
+
|
|
64
|
+
const exportFullLocal = evaluateTrustGuards(exportGuards, { isLocal: true, confirmed: false });
|
|
65
|
+
assert.equal(exportFullLocal.allowed, false);
|
|
66
|
+
assert.ok(exportFullLocal.blocked.includes("confirmation"));
|
|
67
|
+
|
|
68
|
+
// Dispatch enforcement is guards-driven: every slash command declaring a
|
|
69
|
+
// localhost/trusted-context guard must block remote contexts, sensitive or not.
|
|
70
|
+
for (const surface of parity.surfaces) {
|
|
71
|
+
if (surface.kind !== "slash-command") continue;
|
|
72
|
+
const guards = guardsForNativeCommand(surface.command.name, parity);
|
|
73
|
+
const dispatchGuarded = guards.some((guard) => guard === "localhost" || guard === "trusted-context");
|
|
74
|
+
const remoteEvaluation = evaluateDispatchTrustGuards(guards, { isLocal: false, confirmed: true, networkOpen: true });
|
|
75
|
+
assert.equal(
|
|
76
|
+
remoteEvaluation.allowed,
|
|
77
|
+
!dispatchGuarded,
|
|
78
|
+
`/${surface.command.name} remote dispatch should be ${dispatchGuarded ? "blocked" : "allowed"} based on its guards`,
|
|
79
|
+
);
|
|
80
|
+
const localEvaluation = evaluateDispatchTrustGuards(guards, { isLocal: true, confirmed: true, networkOpen: false });
|
|
81
|
+
assert.equal(localEvaluation.allowed, true, `/${surface.command.name} localhost dispatch should be allowed`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
assert.throws(
|
|
85
|
+
() => assertNativeCommandTrust(remoteReq, "export", parity),
|
|
86
|
+
(error) => error.statusCode === 403 && error.trust?.command === "export",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const parsedExport = parseSlashCommand("/export out.html", new Set(["export", "copy"]));
|
|
90
|
+
assert.deepEqual(parsedExport, { name: "export", args: "out.html", text: "/export out.html" });
|
|
91
|
+
assert.equal(parseSlashCommand("/copy", new Set(["export"])), undefined);
|
|
92
|
+
|
|
93
|
+
const success = nativeCommandResponse(
|
|
94
|
+
"copy",
|
|
95
|
+
{ status: "succeeded", message: "Copied the last assistant message.", copyText: "hello" },
|
|
96
|
+
parity,
|
|
97
|
+
);
|
|
98
|
+
assert.equal(success.command, "native_slash_command");
|
|
99
|
+
assert.equal(success.success, true);
|
|
100
|
+
assert.equal(success.data.command, "copy");
|
|
101
|
+
assert.equal(success.data.status, "succeeded");
|
|
102
|
+
assert.ok(Array.isArray(success.data.cards) && success.data.cards.length === 1);
|
|
103
|
+
assert.equal(success.data.cards[0].content, "Copied the last assistant message.");
|
|
104
|
+
assert.deepEqual(success.data.refresh, ["state"]);
|
|
105
|
+
|
|
106
|
+
const unavailable = nativeCommandUnavailable("import", {}, parity);
|
|
107
|
+
assert.equal(unavailable.data.status, "unavailable");
|
|
108
|
+
assert.ok(unavailable.data.message.includes("/import is not available"));
|
|
109
|
+
assert.ok(Array.isArray(unavailable.data.cards));
|
|
110
|
+
|
|
111
|
+
const blocked = nativeCommandBlocked("export", remoteReq, parity, { networkOpen: true });
|
|
112
|
+
assert.equal(blocked.data.status, "blocked");
|
|
113
|
+
assert.match(blocked.data.message, /blocked/i);
|
|
114
|
+
|
|
115
|
+
const hotkeysSurface = nativeParitySurfaceForCommand("hotkeys", parity);
|
|
116
|
+
assert.equal(hotkeysSurface.webStatus, "degraded");
|
|
117
|
+
const hotkeys = nativeCommandResponse("hotkeys", { status: "degraded", message: "keys" }, parity);
|
|
118
|
+
assert.equal(hotkeys.data.status, "degraded");
|
|
119
|
+
|
|
120
|
+
const reload = nativeCommandResponse("reload", { status: "succeeded", message: "ok", tab: { id: "t1" }, refresh: ["tabs", "state", "commands"] }, parity);
|
|
121
|
+
assert.deepEqual(reload.data.refresh, ["tabs", "state", "commands"]);
|
|
122
|
+
|
|
123
|
+
for (const status of ["succeeded", "degraded", "unavailable", "confirmation_required", "blocked"]) {
|
|
124
|
+
assert.ok(NATIVE_COMMAND_STATUSES.has(status), `adapter should support status ${status}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const target of ["state", "tabs", "commands", "themes", "workspace"]) {
|
|
128
|
+
assert.ok(NATIVE_REFRESH_TARGETS.has(target), `adapter should support refresh target ${target}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const slashCommands = nativeSlashCommandEntries(parity);
|
|
132
|
+
assert.equal(slashCommands.length, 24);
|
|
133
|
+
assert.equal(slashCommands[0].name, "settings");
|
|
134
|
+
assert.equal(slashCommands.at(-1).name, "quit");
|
|
135
|
+
|
|
136
|
+
const warning = remoteShellTrustWarning(remoteReq, true);
|
|
137
|
+
assert.match(warning, /not on localhost/i);
|
|
138
|
+
assert.equal(remoteShellTrustWarning(localReq, true), undefined);
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(rpcSuccess("get_state", { ok: true }), {
|
|
141
|
+
type: "response",
|
|
142
|
+
command: "get_state",
|
|
143
|
+
success: true,
|
|
144
|
+
data: { ok: true },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
console.log("native-parity-harness.test.mjs passed");
|
|
@@ -108,14 +108,28 @@ for (const id of [
|
|
|
108
108
|
const surface = parity.surfaces.find((item) => item.id === id);
|
|
109
109
|
assert.ok(surface, `P0 foundation surface ${id} should be tracked`);
|
|
110
110
|
assert.equal(surface.priority, "P0", `${id} should remain P0`);
|
|
111
|
+
assert.equal(surface.webStatus, "implemented", `${id} should be implemented`);
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "server should load the native parity matrix file");
|
|
114
|
-
assert.match(server, /
|
|
115
|
-
assert.match(server, /
|
|
116
|
-
assert.match(server, /
|
|
117
|
-
assert.match(server, /
|
|
118
|
-
assert.match(server, /default:\n\s+return
|
|
115
|
+
assert.match(server, /from "\.\.\/lib\/native-command-adapter\.mjs"/, "server should import the native command adapter module");
|
|
116
|
+
assert.match(server, /from "\.\.\/lib\/trust-boundaries\.mjs"/, "server should import the shared trust-boundaries module");
|
|
117
|
+
assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(nativeParityMatrix\)/, "native slash commands should use the matrix-derived source of truth");
|
|
118
|
+
assert.match(server, /const respondNative = \(command, data = \{\}\) => nativeCommandResponse\(command, data, nativeParityMatrix\)/, "server should bind native command responses to the parity matrix");
|
|
119
|
+
assert.match(server, /default:\n\s+return unavailableNative\(parsed\.name\)/, "unsupported native commands should return structured unavailable cards instead of raw HTTP errors");
|
|
120
|
+
assert.match(server, /return nativeCommandBlocked\(parsed\.name, req, nativeParityMatrix/, "guarded native commands should return blocked adapter cards for failed trust checks");
|
|
121
|
+
assert.match(
|
|
122
|
+
server,
|
|
123
|
+
/const evaluation = evaluateDispatchTrustGuards\(guardsForNativeCommand\(parsed\.name, nativeParityMatrix\)/,
|
|
124
|
+
"native command dispatch should evaluate matrix guards for every command, not only sensitive ones",
|
|
125
|
+
);
|
|
126
|
+
assert.doesNotMatch(server, /if \(surface\?\.sensitive\)/, "native command dispatch must not key trust checks on the sensitive flag");
|
|
127
|
+
assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "localhost-only API routes should use the shared trust-boundaries helper");
|
|
128
|
+
assert.match(server, /remoteShellTrustWarning\(req, networkStatus\(\)\.open\)/, "remote bash clients should receive LAN shell trust warnings");
|
|
129
|
+
assert.match(server, /url\.pathname === "\/api\/session-rename" && req\.method === "POST"/, "server should expose POST /api/session-rename for resume metadata rename");
|
|
130
|
+
assert.match(server, /url\.pathname === "\/api\/session-delete" && req\.method === "POST"/, "server should expose localhost-only POST /api/session-delete");
|
|
131
|
+
assert.match(server, /url\.pathname === "\/api\/auth-providers" && req\.method === "GET"/, "server should expose GET /api/auth-providers");
|
|
132
|
+
assert.match(server, /url\.pathname === "\/api\/auth-logout" && req\.method === "POST"/, "server should expose localhost-only POST /api/auth-logout");
|
|
119
133
|
assert.match(server, /url\.pathname === "\/api\/native-parity" && req\.method === "GET"/, "server should expose the native parity matrix for clients/tests");
|
|
120
134
|
assert.match(server, /const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 \* 60 \* 1000/, "native downloads should use short-lived tokens");
|
|
121
135
|
assert.match(server, /const WEBUI_HELPER_COMMAND = "webui-helper"/, "server should declare the hidden Web UI RPC helper command");
|
|
@@ -136,7 +150,9 @@ assert.match(server, /tab\.rpc\.send\(\{ type: "export_html", outputPath \}\)/,
|
|
|
136
150
|
assert.match(server, /registerNativeDownload\(exportedPath/, "no-path /export should return a short-lived browser download token");
|
|
137
151
|
assert.match(server, /copyFile\(sessionFile, targetPath\)/, "explicit .jsonl /export should copy the active session file");
|
|
138
152
|
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should know how to trigger native command downloads");
|
|
139
|
-
assert.match(app, /response
|
|
153
|
+
assert.match(app, /function applyNativeSlashCommandEffects\(response, message, tabContext/, "frontend should apply centralized native slash-command adapter effects");
|
|
154
|
+
assert.match(app, /data\.download && triggerNativeDownload\(data\.download\)/, "frontend should handle download responses from native commands");
|
|
155
|
+
assert.match(app, /for \(const warning of response\.warnings/, "frontend should surface remote bash trust warnings");
|
|
140
156
|
assert.match(server, /case "\/api\/bash": \{[\s\S]*?return \{ type: "bash", command, excludeFromContext: body\.excludeFromContext === true \}/, "server should expose RPC bash with include/exclude context semantics");
|
|
141
157
|
assert.match(server, /case "\/api\/abort-bash":[\s\S]*?return \{ type: "abort_bash" \}/, "server should expose abort_bash for user bash cancellation");
|
|
142
158
|
assert.match(app, /function parseUserBashInput\(message\)[\s\S]*?text\.startsWith\("!!"\)/, "frontend should detect !! bash commands before prompt forwarding");
|
|
@@ -166,4 +182,7 @@ assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTab
|
|
|
166
182
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
|
|
167
183
|
assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
|
|
168
184
|
assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
|
|
185
|
+
assert.ok(pkg.files.includes("lib"), "published package should include shared Web UI foundation modules");
|
|
169
186
|
assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");
|
|
187
|
+
|
|
188
|
+
console.log("native-parity.test.mjs passed");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const testsDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const files = (await readdir(testsDir)).filter((name) => name.endsWith(".test.mjs")).sort();
|
|
8
|
+
|
|
9
|
+
const failures = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const result = spawnSync(process.execPath, [join(testsDir, file)], { stdio: "inherit" });
|
|
12
|
+
if (result.status !== 0) failures.push(`${file} (exit ${result.status ?? "signal"})`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (failures.length) {
|
|
16
|
+
console.error(`\n${failures.length}/${files.length} test file(s) failed:\n ${failures.join("\n ")}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
console.log(`\nall ${files.length} test files passed`);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { authProvidersPayload, createAuthContext } from "../lib/auth-actions.mjs";
|
|
8
|
+
import {
|
|
9
|
+
collectOpenSessionFiles,
|
|
10
|
+
deleteSessionFile,
|
|
11
|
+
isSessionPathAllowed,
|
|
12
|
+
renameSessionMetadata,
|
|
13
|
+
validateSessionDelete,
|
|
14
|
+
} from "../lib/session-actions.mjs";
|
|
15
|
+
import { LOCALHOST_ONLY_POST_ROUTES } from "../lib/trust-boundaries.mjs";
|
|
16
|
+
|
|
17
|
+
function sessionHeaderLine(cwd, id = "sample-session") {
|
|
18
|
+
return `${JSON.stringify({
|
|
19
|
+
type: "session",
|
|
20
|
+
version: 3,
|
|
21
|
+
id,
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
cwd,
|
|
24
|
+
})}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), "pi-webui-session-auth-"));
|
|
28
|
+
const outsideDir = await mkdtemp(path.join(tmpdir(), "pi-webui-session-outside-"));
|
|
29
|
+
try {
|
|
30
|
+
const createdPath = path.join(tempDir, "sample.jsonl");
|
|
31
|
+
await writeFile(createdPath, sessionHeaderLine(tempDir), "utf8");
|
|
32
|
+
|
|
33
|
+
const renamed = await renameSessionMetadata(createdPath, "Live test session", tempDir);
|
|
34
|
+
assert.equal(renamed.name, "Live test session");
|
|
35
|
+
|
|
36
|
+
const reopened = SessionManager.open(createdPath, tempDir);
|
|
37
|
+
assert.equal(reopened.getSessionName(), "Live test session");
|
|
38
|
+
|
|
39
|
+
const openFiles = collectOpenSessionFiles([
|
|
40
|
+
{ sessionFile: createdPath },
|
|
41
|
+
{ lastState: { sessionFile: "/other/session.jsonl" } },
|
|
42
|
+
]);
|
|
43
|
+
assert.ok(openFiles.has(path.resolve(createdPath)));
|
|
44
|
+
|
|
45
|
+
const needsConfirm = validateSessionDelete(createdPath, {
|
|
46
|
+
openSessionFiles: new Set(),
|
|
47
|
+
currentSessionFile: undefined,
|
|
48
|
+
confirmed: false,
|
|
49
|
+
});
|
|
50
|
+
assert.equal(needsConfirm.allowed, false);
|
|
51
|
+
assert.equal(needsConfirm.reason, "confirmation_required");
|
|
52
|
+
|
|
53
|
+
const blockedActive = validateSessionDelete(createdPath, {
|
|
54
|
+
openSessionFiles: new Set(),
|
|
55
|
+
currentSessionFile: createdPath,
|
|
56
|
+
confirmed: true,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(blockedActive.allowed, false);
|
|
59
|
+
assert.equal(blockedActive.reason, "active_session");
|
|
60
|
+
|
|
61
|
+
const blockedOpenTab = validateSessionDelete(createdPath, {
|
|
62
|
+
openSessionFiles: new Set([path.resolve(createdPath)]),
|
|
63
|
+
currentSessionFile: undefined,
|
|
64
|
+
confirmed: true,
|
|
65
|
+
});
|
|
66
|
+
assert.equal(blockedOpenTab.allowed, false);
|
|
67
|
+
assert.equal(blockedOpenTab.reason, "session_in_use");
|
|
68
|
+
|
|
69
|
+
// Session-directory confinement (path traversal hardening).
|
|
70
|
+
const outsidePath = path.join(outsideDir, "outside.jsonl");
|
|
71
|
+
await writeFile(outsidePath, sessionHeaderLine(outsideDir, "outside-session"), "utf8");
|
|
72
|
+
|
|
73
|
+
assert.equal(isSessionPathAllowed(createdPath, [tempDir]), true);
|
|
74
|
+
assert.equal(isSessionPathAllowed(outsidePath, [tempDir]), false);
|
|
75
|
+
assert.equal(isSessionPathAllowed(path.join(tempDir, "..", "escape.jsonl"), [tempDir]), false);
|
|
76
|
+
assert.equal(isSessionPathAllowed(outsidePath, []), true, "empty allowedDirs must mean no confinement");
|
|
77
|
+
|
|
78
|
+
const blockedOutside = validateSessionDelete(outsidePath, {
|
|
79
|
+
openSessionFiles: new Set(),
|
|
80
|
+
currentSessionFile: undefined,
|
|
81
|
+
confirmed: true,
|
|
82
|
+
allowedDirs: [tempDir],
|
|
83
|
+
});
|
|
84
|
+
assert.equal(blockedOutside.allowed, false);
|
|
85
|
+
assert.equal(blockedOutside.reason, "outside_session_dir");
|
|
86
|
+
|
|
87
|
+
const allowedInside = validateSessionDelete(createdPath, {
|
|
88
|
+
openSessionFiles: new Set(),
|
|
89
|
+
currentSessionFile: undefined,
|
|
90
|
+
confirmed: true,
|
|
91
|
+
allowedDirs: [tempDir],
|
|
92
|
+
});
|
|
93
|
+
assert.equal(allowedInside.allowed, true);
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
renameSessionMetadata(outsidePath, "blocked rename", tempDir, { allowedDirs: [tempDir] }),
|
|
97
|
+
/session directory/i,
|
|
98
|
+
"rename outside the session dir must be rejected",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
deleteSessionFile(outsidePath, { allowedDirs: [tempDir] }),
|
|
103
|
+
/session directory/i,
|
|
104
|
+
"delete outside the session dir must be rejected",
|
|
105
|
+
);
|
|
106
|
+
assert.ok(existsSync(outsidePath), "blocked delete must not remove the file");
|
|
107
|
+
|
|
108
|
+
// Unlink fallback: force `trash` lookup failure via empty PATH, file must still go away.
|
|
109
|
+
const deletablePath = path.join(tempDir, "deletable.jsonl");
|
|
110
|
+
await writeFile(deletablePath, sessionHeaderLine(tempDir, "deletable-session"), "utf8");
|
|
111
|
+
const savedPath = process.env.PATH;
|
|
112
|
+
process.env.PATH = path.join(tempDir, "no-bin");
|
|
113
|
+
let deleted;
|
|
114
|
+
try {
|
|
115
|
+
deleted = await deleteSessionFile(deletablePath, { allowedDirs: [tempDir] });
|
|
116
|
+
} finally {
|
|
117
|
+
process.env.PATH = savedPath;
|
|
118
|
+
}
|
|
119
|
+
assert.equal(deleted.method, "unlink");
|
|
120
|
+
assert.equal(existsSync(deletablePath), false);
|
|
121
|
+
|
|
122
|
+
const auth = createAuthContext();
|
|
123
|
+
const payload = authProvidersPayload(auth.modelRegistry);
|
|
124
|
+
assert.ok(Array.isArray(payload.loginProviders));
|
|
125
|
+
assert.ok(Array.isArray(payload.logoutProviders));
|
|
126
|
+
assert.equal(payload.browserLoginSupported, false);
|
|
127
|
+
assert.match(payload.guidance, /Pi TUI/i);
|
|
128
|
+
|
|
129
|
+
assert.ok(LOCALHOST_ONLY_POST_ROUTES.has("/api/session-delete"));
|
|
130
|
+
assert.ok(LOCALHOST_ONLY_POST_ROUTES.has("/api/auth-logout"));
|
|
131
|
+
assert.ok(
|
|
132
|
+
LOCALHOST_ONLY_POST_ROUTES.has("/api/network/close"),
|
|
133
|
+
"closing network access must be localhost-only like opening it",
|
|
134
|
+
);
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
137
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log("session-auth-harness.test.mjs passed");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { sweepStaleTempEntries } from "../lib/temp-artifacts.mjs";
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), "pi-webui-temp-sweep-"));
|
|
8
|
+
try {
|
|
9
|
+
const missing = await sweepStaleTempEntries(path.join(root, "missing"), { ttlMs: 1_000 });
|
|
10
|
+
assert.deepEqual(missing, [], "missing directory must sweep to nothing without throwing");
|
|
11
|
+
|
|
12
|
+
const staleDir = path.join(root, "stale-upload");
|
|
13
|
+
await mkdir(staleDir, { recursive: true });
|
|
14
|
+
await writeFile(path.join(staleDir, "attachment.txt"), "old", "utf8");
|
|
15
|
+
const freshDir = path.join(root, "fresh-upload");
|
|
16
|
+
await mkdir(freshDir, { recursive: true });
|
|
17
|
+
const staleFile = path.join(root, "stale-export.html");
|
|
18
|
+
await writeFile(staleFile, "old", "utf8");
|
|
19
|
+
const freshFile = path.join(root, "fresh-export.html");
|
|
20
|
+
await writeFile(freshFile, "new", "utf8");
|
|
21
|
+
|
|
22
|
+
const past = new Date(Date.now() - 60_000);
|
|
23
|
+
await utimes(staleDir, past, past);
|
|
24
|
+
await utimes(staleFile, past, past);
|
|
25
|
+
|
|
26
|
+
const removed = await sweepStaleTempEntries(root, { ttlMs: 30_000 });
|
|
27
|
+
assert.deepEqual(new Set(removed), new Set([staleDir, staleFile]), "only entries older than the TTL are removed");
|
|
28
|
+
|
|
29
|
+
const remaining = (await readdir(root)).sort();
|
|
30
|
+
assert.deepEqual(remaining, ["fresh-export.html", "fresh-upload"], "fresh entries must survive the sweep");
|
|
31
|
+
|
|
32
|
+
const repeat = await sweepStaleTempEntries(root, { ttlMs: 30_000 });
|
|
33
|
+
assert.deepEqual(repeat, [], "repeat sweep with no stale entries removes nothing");
|
|
34
|
+
} finally {
|
|
35
|
+
await rm(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log("temp-artifacts-harness.test.mjs passed");
|