@firstpick/pi-package-webui 0.3.7 → 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 +233 -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 +547 -93
- package/public/index.html +2 -2
- package/public/service-worker.js +23 -9
- package/public/styles.css +111 -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 +44 -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
|
@@ -27,6 +27,7 @@ const companionDependencies = {
|
|
|
27
27
|
"@firstpick/pi-extension-git-footer-status": "^0.2.1",
|
|
28
28
|
"@firstpick/pi-extension-release-aur": "^0.1.3",
|
|
29
29
|
"@firstpick/pi-extension-release-npm": "^0.3.3",
|
|
30
|
+
"@firstpick/pi-extension-safety-guard": "^0.2.3",
|
|
30
31
|
"@firstpick/pi-extension-setup-skills": "^0.1.5",
|
|
31
32
|
"@firstpick/pi-extension-stats": "^0.2.0",
|
|
32
33
|
"@firstpick/pi-extension-todo-progress": "^0.1.7",
|
|
@@ -147,6 +148,7 @@ assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu s
|
|
|
147
148
|
assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
|
|
148
149
|
assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
|
|
149
150
|
assert.match(html, /id="followUpButton"[\s\S]*?data-tooltip="Follow-up usage:/, "Follow-up should explain type-first usage in a tooltip");
|
|
151
|
+
assert.match(html, /id="gitWorkflowButton"[\s\S]*?data-tooltip="Guided Git workflow:[\s\S]*Optional: create or type a PR branch[\s\S]*Push normally, or push the PR branch, generate\/review \/pr, and create the PR/, "Git workflow tooltip should describe the current commit-or-PR flow");
|
|
150
152
|
assert.ok(
|
|
151
153
|
html.indexOf('<main class="layout">') < html.indexOf('id="sidePanelBackdrop"') &&
|
|
152
154
|
html.indexOf('id="sidePanelBackdrop"') < html.indexOf('id="sidePanel"'),
|
|
@@ -309,9 +311,12 @@ assert.match(css, /\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(-
|
|
|
309
311
|
assert.match(css, /\.footer-thinking \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "git-footer effort chip should have its own styling");
|
|
310
312
|
assert.match(css, /\.footer-changes \{[\s\S]*?border-color:\s*rgba\(249, 226, 175, 0\.36\)/, "git-footer changes chip should use a higher-contrast warning tint");
|
|
311
313
|
assert.match(css, /\.footer-changes \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-yellow\)[\s\S]*?font-weight:\s*950/, "git-footer changes value should be bright and bold");
|
|
314
|
+
assert.match(css, /\.footer-changed-files-popover \{[\s\S]*?position:\s*absolute;[\s\S]*?bottom:\s*calc\(100% \+ 0\.48rem\)[\s\S]*?max-height:/, "git-footer changes chip should expose a hover popover for changed files");
|
|
315
|
+
assert.match(css, /\.footer-changes-with-files:hover \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus-within \.footer-changed-files-popover \{[\s\S]*?display:\s*grid/, "git-footer changed-files popover should open on hover or keyboard focus");
|
|
316
|
+
assert.match(css, /\.footer-changed-file\.modified \.footer-changed-file-status \{ color:\s*var\(--ctp-yellow\); \}/, "modified changed-file rows should keep the changes warning color");
|
|
312
317
|
assert.match(css, /\.footer-git-extra \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-sky\)[\s\S]*?font-weight:\s*900/, "git-footer extras value should be bright enough to read at footer size");
|
|
313
|
-
assert.match(css, /\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
|
|
314
|
-
assert.doesNotMatch(css, /\.footer-meta-action::after/, "clickable footer boxes should not show a corner indicator dot");
|
|
318
|
+
assert.match(css, /\.footer-metric-action,\n\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
|
|
319
|
+
assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable footer boxes should not show a corner indicator dot");
|
|
315
320
|
assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
|
|
316
321
|
assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
|
|
317
322
|
assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
|
|
@@ -490,12 +495,15 @@ assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggle
|
|
|
490
495
|
assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
|
|
491
496
|
assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git footer Web UI data should be received as an extension-owned status payload");
|
|
492
497
|
assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
|
|
498
|
+
assert.match(app, /function normalizeFooterPayloadChangedFile\(value\)[\s\S]*FOOTER_CHANGED_FILE_KINDS\.has\(value\.kind\)[\s\S]*oldPath/, "git footer payload parsing should preserve changed-file details for changes popovers");
|
|
499
|
+
assert.match(app, /const files = value\.files\.map\(normalizeFooterPayloadChangedFile\)\.filter\(Boolean\)\.slice\(0, 80\);[\s\S]*chip\.files = files;/, "git footer payload chips should retain bounded changed-file lists");
|
|
493
500
|
assert.match(app, /title: cleanFooterPayloadText\(value\.title, "", 4000\)/, "git footer tooltip titles should preserve long cwd paths instead of truncating at chip display length");
|
|
494
501
|
assert.match(app, /const sourceTitle = cleanFooterPayloadText\(chip\?\.title, "", 4000\)/, "git footer tooltip rendering should keep full source titles for long cwd paths");
|
|
495
502
|
assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(footerPayloadWithLiveModel\(gitFooterPayload\)\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
|
|
496
503
|
assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*?shortModelLabel\(currentState\.model\)[\s\S]*?footerThinkingDisplay\(\)[\s\S]*?key: "thinking", label: "effort"/, "git footer payload rendering should split model and effort chips from live Web UI state");
|
|
497
|
-
assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*
|
|
504
|
+
assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*footerAutoCompactionEnabled\(state\)[\s\S]*`\$\{withoutAuto\} \(auto\)`/, "context displays should append the auto-compaction indicator when enabled");
|
|
498
505
|
assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*const contextChip = \(chip\)[\s\S]*footerContextDisplayWithAuto\(chip\?\.value\)[\s\S]*if \(chip\?\.key === "context"\) return \[contextChip\(chip\)\]/, "git footer context chips should use live Web UI auto-compaction state");
|
|
506
|
+
assert.match(app, /async function toggleFooterAutoCompaction\(tabContext = activeTabContext\(\)\)[\s\S]*currentState = \{ \.\.\.currentState, autoCompactionEnabled: enabled \}[\s\S]*api\("\/api\/auto-compaction", \{ method: "POST", body: \{ enabled \}, tabId: tabContext\.tabId \}\)/, "git footer context box should optimistically toggle auto-compaction through the Web UI API");
|
|
499
507
|
assert.match(app, /function renderGitFooterPayload\(payload\)[\s\S]*classList\.remove\("statusbar-tui-footer"\)[\s\S]*classList\.add\("statusbar-git-footer"\)[\s\S]*payload\.main\.map\(renderGitFooterPayloadMetric\)[\s\S]*payload\.meta\.map/, "enabled git footer payload should use the styled extension chip renderer, not the default TUI line");
|
|
500
508
|
assert.match(app, /function ensureFooterTooltipNode\(\)[\s\S]*footer-floating-tooltip[\s\S]*document\.body\.append\(footerTooltipNode\)/, "git footer tooltips should render into a single floating viewport-level node");
|
|
501
509
|
const footerTooltipSource = app.match(/function applyFooterTooltip\(node, tooltip, options = \{\}\)[\s\S]*?\n}\n\nfunction footerMetric/)?.[0] || "";
|
|
@@ -505,8 +513,10 @@ assert.match(app, /const GIT_FOOTER_TOOLTIP_COPY = \{[\s\S]*tokens:[\s\S]*cache:
|
|
|
505
513
|
assert.match(app, /function gitFooterPayloadTooltip\(chip, options = \{\}\)[\s\S]*GIT_FOOTER_TOOLTIP_COPY\[key\][\s\S]*`Current: \$\{value\}`/, "git footer tooltips should combine explanations with the current chip value");
|
|
506
514
|
assert.match(app, /function isRedundantFooterTooltipTitle\(sourceTitle, chip, value\)[\s\S]*labels\.map\(\(label\) => `\$\{label\}: \$\{value\}`\)/, "git footer tooltips should suppress duplicate label/current title lines");
|
|
507
515
|
assert.match(app, /function gitFooterTooltipAlign\(chip\)[\s\S]*\["tokens", "cwd"\][\s\S]*return "start";[\s\S]*\["model", "thinking"\][\s\S]*return "end";/, "git footer tooltip alignment should keep edge boxes readable");
|
|
508
|
-
assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*
|
|
509
|
-
assert.match(app, /function
|
|
516
|
+
assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*applyGitFooterContextToggleOptions\(chip, options\)[\s\S]*gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMetric\(chip\.icon/, "git footer main payload chips should render as styled metrics with explanatory tooltips and context action support");
|
|
517
|
+
assert.match(app, /function applyFooterChangedFilesDropdown\(node, chip\)[\s\S]*chip\?\.key !== "changes"[\s\S]*footer-changes-with-files[\s\S]*footer-changed-files-popover/, "git footer changes chip should render a changed-files hover popover when files are present");
|
|
518
|
+
assert.match(app, /function insertChangedFilePathReference\(path\)[\s\S]*formatPathReference\(path\)[\s\S]*input\.focus\(\)/, "clicking changed files should insert an @path reference and focus the composer");
|
|
519
|
+
assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\), options\)[\s\S]*applyFooterChangedFilesDropdown\(node, chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips and changes popovers");
|
|
510
520
|
assert.match(app, /chip\.key === "git"[\s\S]*setFooterBranchPickerOpen\(!footerBranchPickerOpen\)[\s\S]*Click to switch to another local branch/, "git branch footer chip should open the branch picker");
|
|
511
521
|
assert.match(app, /function renderFooterBranchPicker\(\)[\s\S]*Git branches[\s\S]*applyFooterGitBranch\(branch\.name\)/, "git branch picker should render available branches and switch on selection");
|
|
512
522
|
assert.match(app, /Create new branch[\s\S]*createFooterGitBranch\(\)/, "git branch picker should offer branch creation when no other branches are available");
|
|
@@ -524,7 +534,7 @@ assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*
|
|
|
524
534
|
assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
|
|
525
535
|
assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
|
|
526
536
|
assert.match(app, /statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "optional feature detection should recognize the git-footer-status Web UI payload");
|
|
527
|
-
assert.match(app,
|
|
537
|
+
assert.match(app, /message: `\/\$\{refreshCommand\} --webui-silent`/, "Web UI should quietly request the extension-owned footer payload when idle and missing, using the loaded RPC command name");
|
|
528
538
|
assert.match(app, /function requestGitFooterWebuiPayload\(tabContext = activeTabContext\(\), \{ force = false \} = \{\}\)[\s\S]*?!force && statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "git footer payload refresh should support forced refresh even when a live payload already exists");
|
|
529
539
|
assert.doesNotMatch(app, /function requestGitFooterWebuiPayload\([\s\S]*?statusEntries\.delete\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "forced git footer refreshes should keep the existing payload visible while the refresh runs");
|
|
530
540
|
assert.match(app, /function applyOptimisticModelSelection\(model, tabContext = activeTabContext\(\)\)[\s\S]*?currentState = \{ \.\.\.currentState, model: nextModel \}[\s\S]*?renderStatus\(\)[\s\S]*?requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "model changes should update current state and footer immediately before async refreshes complete");
|
|
@@ -548,13 +558,16 @@ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiS
|
|
|
548
558
|
assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
|
|
549
559
|
assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
|
|
550
560
|
assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
|
|
561
|
+
assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
|
|
551
562
|
assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
|
|
552
563
|
assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
|
|
553
|
-
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
564
|
+
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
565
|
+
assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
|
|
566
|
+
assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
|
|
554
567
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
|
|
555
568
|
assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
|
|
556
569
|
assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
|
|
557
|
-
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*
|
|
570
|
+
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*resolveAvailableCommandName\(commandName, \{ rpcOnly: true \}\)/, "publish workflow launch should guard on loaded slash commands, including duplicate-suffixed RPC command names");
|
|
558
571
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
|
|
559
572
|
assert.doesNotMatch(html, /gitWorkflowProcessSelect/, "guided git workflow should not expose process selection as a dropdown");
|
|
560
573
|
assert.match(app, /const GIT_WORKFLOW_PROCESSES = \[[\s\S]*value: "stage", label: "Stage"[\s\S]*value: "message", label: "Message"[\s\S]*value: "commit", label: "Commit"[\s\S]*value: "push", label: "Push"/, "guided git workflow should define Stage/Message/Commit/Push process buttons");
|
|
@@ -572,8 +585,8 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
|
|
|
572
585
|
assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
|
|
573
586
|
assert.match(html, /id="gitPrDialog"[\s\S]*id="gitPrTitleInput"[\s\S]*id="gitPrBodyEditor"[\s\S]*id="gitPrCreateButton"/, "guided git workflow should expose a PR review dialog with title and body editing");
|
|
574
587
|
assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\(\), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP\)/, "guided git workflow should offer a Create PR branch action after message generation");
|
|
575
|
-
assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"
|
|
576
|
-
assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"
|
|
588
|
+
assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"Create PR branch:"[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Create PR should have an up-to-date step-by-step tooltip");
|
|
589
|
+
assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"Manual PR branch:"[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Manual branch should have an up-to-date step-by-step tooltip");
|
|
577
590
|
assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
|
|
578
591
|
assert.match(css, /\.git-workflow-actions button\[data-tooltip\]::after \{[\s\S]*content:\s*attr\(data-tooltip\)[\s\S]*white-space:\s*pre-line/, "guided git workflow action tooltips should render multiline step lists");
|
|
579
592
|
assert.match(app, /function gitBranchNamePromptMessage\(\)[\s\S]*hasAvailableCommand\("git-branch-name"\)[\s\S]*return "\/git-branch-name"/, "guided git workflow should ask the agent to generate PR branch names when the prompt is available");
|
|
@@ -748,7 +761,7 @@ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*cho
|
|
|
748
761
|
assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
|
|
749
762
|
assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
|
|
750
763
|
assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
|
|
751
|
-
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt",
|
|
764
|
+
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", `\/\$\{resolvedCommandName\}\$\{commandRest\}`\)/, "Publish workflows should send resolved slash commands directly without replacing the draft");
|
|
752
765
|
assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
|
|
753
766
|
assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "generic native command menu should fall back to slash-command prompt execution");
|
|
754
767
|
assert.match(app, /function setOptionsMenuOpen\(open\)/, "Options menu should have explicit open state");
|
|
@@ -781,8 +794,11 @@ assert.match(app, /function openNativeSettingsDialog\(\)[\s\S]*?\/api\/steering-
|
|
|
781
794
|
assert.match(app, /function openNativeNameDialog\(\)[\s\S]*?sendPrompt\("prompt", `\/name \$\{name\}`\)/, "native /name selector should prompt before running the slash command");
|
|
782
795
|
assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messages[\s\S]*?\/api\/fork/, "native /fork selector should pair fork-point loading with the fork action");
|
|
783
796
|
assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
|
|
797
|
+
assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
|
|
798
|
+
assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
|
|
784
799
|
assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
|
|
785
|
-
assert.match(app, /
|
|
800
|
+
assert.match(app, /async function openNativeAuthSelector\(mode\)[\s\S]*?\/api\/auth-providers[\s\S]*?Browser login is not implemented yet/, "native /login should list provider status without browser credential entry");
|
|
801
|
+
assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
|
|
786
802
|
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
|
|
787
803
|
assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
|
|
788
804
|
assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
|
|
@@ -896,8 +912,8 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
|
|
|
896
912
|
assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isSidePanelOverlayView\(\)\)/, "mobile and narrow overlay layouts should start with side panel collapsed even if desktop state was expanded");
|
|
897
913
|
assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
|
|
898
914
|
assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
|
|
899
|
-
assert.match(app, /
|
|
900
|
-
assert.match(app, /Clipboard access failed:[\s\S]*?
|
|
915
|
+
assert.match(app, /copyText\(data\.copyText\)\.catch/, "native /copy should use the shared browser clipboard helper when available");
|
|
916
|
+
assert.match(app, /Clipboard access failed:[\s\S]*?data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
|
|
901
917
|
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
|
|
902
918
|
assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
|
|
903
919
|
assert.match(app, /loadLegacyFastPicks\(/, "frontend should migrate existing browser-local fast picks");
|
|
@@ -907,7 +923,9 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
|
|
|
907
923
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
|
|
908
924
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
|
|
909
925
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
|
|
910
|
-
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-
|
|
926
|
+
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v\d+"/, "PWA service worker should define a versioned app-shell cache");
|
|
927
|
+
assert.match(serviceWorker, /fetchThenCache\(request\)\.catch\(/, "PWA service worker should serve the app shell network-first with offline cache fallback");
|
|
928
|
+
assert.match(serviceWorker, /ignoreSearch: true/, "PWA service worker offline fallback should ignore ?v= cache busters");
|
|
911
929
|
assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
|
|
912
930
|
assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
|
|
913
931
|
assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
|
|
@@ -924,7 +942,7 @@ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthSt
|
|
|
924
942
|
assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
|
|
925
943
|
assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
|
|
926
944
|
assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
|
|
927
|
-
assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
|
|
945
|
+
assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(nativeParityMatrix\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
|
|
928
946
|
assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptions should come from the parity matrix source of truth");
|
|
929
947
|
assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
|
|
930
948
|
assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
|
|
@@ -992,6 +1010,9 @@ assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copy
|
|
|
992
1010
|
assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
|
|
993
1011
|
assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
|
|
994
1012
|
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
|
|
1013
|
+
assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
|
|
1014
|
+
assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
|
|
1015
|
+
assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
|
|
995
1016
|
assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
|
|
996
1017
|
assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
|
|
997
1018
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
|
|
@@ -1030,6 +1051,7 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
|
|
|
1030
1051
|
assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
|
|
1031
1052
|
assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
|
|
1032
1053
|
assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
|
|
1054
|
+
assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
|
|
1033
1055
|
assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
|
|
1034
1056
|
assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
|
|
1035
1057
|
assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
|
|
@@ -1039,9 +1061,9 @@ assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webu
|
|
|
1039
1061
|
assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
|
|
1040
1062
|
assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
|
|
1041
1063
|
assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
|
|
1042
|
-
assert.match(server, /
|
|
1064
|
+
assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "optional feature install endpoint should use shared localhost trust policy");
|
|
1043
1065
|
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "GET"[\s\S]*?getSkillFileData/, "server should expose GET /api/skill-file for editable skill content");
|
|
1044
|
-
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?
|
|
1066
|
+
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?requireLocalhostRoute\(req, url\.pathname\)[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
|
|
1045
1067
|
assert.match(server, /function resolveEditableSkillFile\(tab, request = \{\}\)[\s\S]*?path\.basename\(skill\.filePath\) !== "SKILL\.md"/, "skill file API should validate that edits target resolved SKILL.md resources");
|
|
1046
1068
|
assert.match(server, /function resolveExplicitSkillFilePath\(tab, filePath, requestedName = ""\)[\s\S]*?Skill path must point to \/skills\/<name>\/SKILL\.md[\s\S]*?allowedRoots/, "skill file API should allow exact read SKILL.md paths from trusted Pi skill roots");
|
|
1047
1069
|
assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
|
|
@@ -1078,8 +1100,7 @@ assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui
|
|
|
1078
1100
|
assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
|
|
1079
1101
|
assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
|
|
1080
1102
|
|
|
1081
|
-
assert.match(pkg.scripts?.test || "", /node tests\/
|
|
1082
|
-
assert.match(pkg.scripts?.test || "", /node tests\/native-parity\.test\.mjs/, "package test script should run the native parity harness");
|
|
1103
|
+
assert.match(pkg.scripts?.test || "", /node tests\/run-all\.mjs/, "package test script should run every tests/*.test.mjs through the shared runner");
|
|
1083
1104
|
assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
|
|
1084
1105
|
assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
|
|
1085
1106
|
for (const [name, range] of Object.entries(companionDependencies)) {
|
|
@@ -1091,6 +1112,7 @@ for (const extensionPath of [
|
|
|
1091
1112
|
"../pi-extension-git-footer-status/index.ts",
|
|
1092
1113
|
"../pi-extension-release-aur/index.ts",
|
|
1093
1114
|
"../pi-extension-release-npm/index.ts",
|
|
1115
|
+
"../pi-extension-safety-guard/index.ts",
|
|
1094
1116
|
"../pi-extension-setup-skills/index.ts",
|
|
1095
1117
|
"../pi-extension-stats/index.ts",
|
|
1096
1118
|
"../pi-extension-todo-progress/index.ts",
|
|
@@ -1098,6 +1120,7 @@ for (const extensionPath of [
|
|
|
1098
1120
|
"node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
|
|
1099
1121
|
"node_modules/@firstpick/pi-extension-release-aur/index.ts",
|
|
1100
1122
|
"node_modules/@firstpick/pi-extension-release-npm/index.ts",
|
|
1123
|
+
"node_modules/@firstpick/pi-extension-safety-guard/index.ts",
|
|
1101
1124
|
"node_modules/@firstpick/pi-extension-setup-skills/index.ts",
|
|
1102
1125
|
"node_modules/@firstpick/pi-extension-stats/index.ts",
|
|
1103
1126
|
"node_modules/@firstpick/pi-extension-todo-progress/index.ts",
|
|
@@ -1112,6 +1135,6 @@ assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/p
|
|
|
1112
1135
|
assert.ok(pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should load sibling bundled themes when present");
|
|
1113
1136
|
assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
|
|
1114
1137
|
assert.ok(pkg.scripts?.check?.includes("node --check public/app.js"), "check script should syntax-check app.js");
|
|
1115
|
-
assert.ok(pkg.scripts?.check?.includes("node tests/
|
|
1138
|
+
assert.ok(pkg.scripts?.check?.includes("node tests/run-all.mjs"), "check script should run the shared test runner");
|
|
1116
1139
|
|
|
1117
1140
|
console.log("mobile static checks passed");
|
|
@@ -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");
|