@firstpick/pi-package-webui 0.3.7 → 0.3.9

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.
@@ -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");
@@ -485,17 +490,21 @@ assert.match(
485
490
  "side-panel section toggles should expand at most one section at a time",
486
491
  );
487
492
  assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex usage buckets in the side panel");
493
+ assert.match(app, /if \(normalized === "prolite"\) return "Usage";/, "Codex Prolite plan labels should display as Usage in the side panel");
488
494
  assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
489
495
  assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
490
496
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
491
497
  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
498
  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");
499
+ 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");
500
+ 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
501
  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
502
  assert.match(app, /const sourceTitle = cleanFooterPayloadText\(chip\?\.title, "", 4000\)/, "git footer tooltip rendering should keep full source titles for long cwd paths");
495
503
  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
504
  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]*autoCompactionEnabled !== false[\s\S]*`\$\{withoutAuto\} \(auto\)`/, "context displays should append the auto-compaction indicator when enabled");
505
+ 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
506
  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");
507
+ 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
508
  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
509
  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
510
  const footerTooltipSource = app.match(/function applyFooterTooltip\(node, tooltip, options = \{\}\)[\s\S]*?\n}\n\nfunction footerMetric/)?.[0] || "";
@@ -505,8 +514,10 @@ assert.match(app, /const GIT_FOOTER_TOOLTIP_COPY = \{[\s\S]*tokens:[\s\S]*cache:
505
514
  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
515
  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
516
  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]*footerMetric\(chip\.icon[\s\S]*gitFooterPayloadTooltip\(chip\)[\s\S]*tooltipAlign: gitFooterTooltipAlign\(chip\)/, "git footer main payload chips should render as styled metrics with explanatory tooltips");
509
- assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*options\.tooltipAlign = gitFooterTooltipAlign\(chip\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips");
517
+ 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");
518
+ 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");
519
+ 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");
520
+ 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
521
  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
522
  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
523
  assert.match(app, /Create new branch[\s\S]*createFooterGitBranch\(\)/, "git branch picker should offer branch creation when no other branches are available");
@@ -524,7 +535,7 @@ assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*
524
535
  assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
525
536
  assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
526
537
  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, /\/git-footer-refresh --webui-silent/, "Web UI should quietly request the extension-owned footer payload when idle and missing");
538
+ 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
539
  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
540
  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
541
  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 +559,16 @@ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiS
548
559
  assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
549
560
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
550
561
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
562
+ assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
551
563
  assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
552
564
  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");
565
+ 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");
566
+ 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");
567
+ 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
568
  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
569
  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
570
  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]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
571
+ 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
572
  assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
559
573
  assert.doesNotMatch(html, /gitWorkflowProcessSelect/, "guided git workflow should not expose process selection as a dropdown");
560
574
  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 +586,8 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
572
586
  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
587
  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
588
  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]*"5\. Return here so you can choose Commit short or Commit long on that branch\."/, "Create PR should have a step-by-step tooltip");
576
- assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"5\. Return here so you can choose Commit short or Commit long on that branch\."/, "Manual branch should have a step-by-step tooltip");
589
+ 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");
590
+ 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
591
  assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
578
592
  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
593
  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 +762,7 @@ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*cho
748
762
  assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
749
763
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
750
764
  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", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
765
+ 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
766
  assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
753
767
  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
768
  assert.match(app, /function setOptionsMenuOpen\(open\)/, "Options menu should have explicit open state");
@@ -781,8 +795,11 @@ assert.match(app, /function openNativeSettingsDialog\(\)[\s\S]*?\/api\/steering-
781
795
  assert.match(app, /function openNativeNameDialog\(\)[\s\S]*?sendPrompt\("prompt", `\/name \$\{name\}`\)/, "native /name selector should prompt before running the slash command");
782
796
  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
797
  assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
798
+ assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
799
+ assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
784
800
  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, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
801
+ 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");
802
+ assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
786
803
  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
804
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
788
805
  assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
@@ -896,8 +913,8 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
896
913
  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
914
  assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
898
915
  assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
899
- assert.match(app, /await copyText\(response\.data\.copyText\)/, "native /copy should use the shared browser clipboard helper when available");
900
- assert.match(app, /Clipboard access failed:[\s\S]*?response\.data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
916
+ assert.match(app, /copyText\(data\.copyText\)\.catch/, "native /copy should use the shared browser clipboard helper when available");
917
+ assert.match(app, /Clipboard access failed:[\s\S]*?data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
901
918
  assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
902
919
  assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
903
920
  assert.match(app, /loadLegacyFastPicks\(/, "frontend should migrate existing browser-local fast picks");
@@ -907,7 +924,9 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
907
924
  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
925
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
909
926
  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-v25"/, "PWA service worker should define an app-shell cache");
927
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v\d+"/, "PWA service worker should define a versioned app-shell cache");
928
+ assert.match(serviceWorker, /fetchThenCache\(request\)\.catch\(/, "PWA service worker should serve the app shell network-first with offline cache fallback");
929
+ assert.match(serviceWorker, /ignoreSearch: true/, "PWA service worker offline fallback should ignore ?v= cache busters");
911
930
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
912
931
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
913
932
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -924,7 +943,7 @@ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthSt
924
943
  assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
925
944
  assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
926
945
  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");
946
+ assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(nativeParityMatrix\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
928
947
  assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptions should come from the parity matrix source of truth");
929
948
  assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
930
949
  assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
@@ -992,6 +1011,9 @@ assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copy
992
1011
  assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
993
1012
  assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
994
1013
  assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
1014
+ assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
1015
+ assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
1016
+ assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
995
1017
  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
1018
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
997
1019
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
@@ -1030,6 +1052,7 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
1030
1052
  assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
1031
1053
  assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
1032
1054
  assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
1055
+ assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
1033
1056
  assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
1034
1057
  assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
1035
1058
  assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
@@ -1039,9 +1062,9 @@ assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webu
1039
1062
  assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
1040
1063
  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
1064
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
1042
- assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
1065
+ assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "optional feature install endpoint should use shared localhost trust policy");
1043
1066
  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]*?Saving skill files is only allowed from localhost[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1067
+ 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
1068
  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
1069
  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
1070
  assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
@@ -1078,8 +1101,7 @@ assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui
1078
1101
  assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
1079
1102
  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
1103
 
1081
- assert.match(pkg.scripts?.test || "", /node tests\/mobile-static\.test\.mjs/, "package test script should run the mobile static harness");
1082
- assert.match(pkg.scripts?.test || "", /node tests\/native-parity\.test\.mjs/, "package test script should run the native parity harness");
1104
+ assert.match(pkg.scripts?.test || "", /node tests\/run-all\.mjs/, "package test script should run every tests/*.test.mjs through the shared runner");
1083
1105
  assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
1084
1106
  assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
1085
1107
  for (const [name, range] of Object.entries(companionDependencies)) {
@@ -1091,6 +1113,7 @@ for (const extensionPath of [
1091
1113
  "../pi-extension-git-footer-status/index.ts",
1092
1114
  "../pi-extension-release-aur/index.ts",
1093
1115
  "../pi-extension-release-npm/index.ts",
1116
+ "../pi-extension-safety-guard/index.ts",
1094
1117
  "../pi-extension-setup-skills/index.ts",
1095
1118
  "../pi-extension-stats/index.ts",
1096
1119
  "../pi-extension-todo-progress/index.ts",
@@ -1098,6 +1121,7 @@ for (const extensionPath of [
1098
1121
  "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
1099
1122
  "node_modules/@firstpick/pi-extension-release-aur/index.ts",
1100
1123
  "node_modules/@firstpick/pi-extension-release-npm/index.ts",
1124
+ "node_modules/@firstpick/pi-extension-safety-guard/index.ts",
1101
1125
  "node_modules/@firstpick/pi-extension-setup-skills/index.ts",
1102
1126
  "node_modules/@firstpick/pi-extension-stats/index.ts",
1103
1127
  "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
@@ -1112,6 +1136,6 @@ assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/p
1112
1136
  assert.ok(pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should load sibling bundled themes when present");
1113
1137
  assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
1114
1138
  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/mobile-static.test.mjs"), "check script should include mobile static assertions");
1139
+ assert.ok(pkg.scripts?.check?.includes("node tests/run-all.mjs"), "check script should run the shared test runner");
1116
1140
 
1117
1141
  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, /function nativeSlashCommandEntries\(matrix = nativeParityMatrix\)/, "server should derive native slash commands from the parity matrix");
115
- assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "native slash commands should use the matrix-derived source of truth");
116
- assert.match(server, /function nativeCommandResponse\(command, data = \{\}\)/, "server should define a centralized native command adapter response helper");
117
- assert.match(server, /function nativeCommandUnavailable\(command, details = \{\}\)/, "server should define structured unavailable native command output");
118
- assert.match(server, /default:\n\s+return nativeCommandUnavailable\(parsed\.name\)/, "unsupported native commands should return structured unavailable cards instead of raw HTTP errors");
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\?\.command === "native_slash_command" && response\.data\?\.download/, "frontend should handle download responses from native commands");
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");