@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.
- package/README.md +2 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +22 -22
- package/bin/pi-webui.mjs +259 -112
- 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 +554 -94
- 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 +45 -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");
|
|
@@ -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]*
|
|
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]*
|
|
509
|
-
assert.match(app, /function
|
|
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,
|
|
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]*
|
|
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]*"
|
|
576
|
-
assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"
|
|
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",
|
|
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, /
|
|
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, /
|
|
900
|
-
assert.match(app, /Clipboard access failed:[\s\S]*?
|
|
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-
|
|
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, /
|
|
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]*?
|
|
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\/
|
|
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/
|
|
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, /
|
|
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");
|