From 96820d6918f727ff7f814c0320d93be242ab2f82 Mon Sep 17 00:00:00 2001 From: ishaan1124 Date: Wed, 12 Aug 2026 23:21:59 +0530 Subject: [PATCH 1/7] feat(app): refine workspace UX and runtime controls --- .github/workflows/ci.yml | 43 +- .openscience/agent/docs.md | 34 - backend/cli/package.json | 2 +- backend/cli/src/agent/agent.ts | 20 +- backend/cli/src/artifact/store.ts | 124 +- backend/cli/src/auth/index.ts | 13 +- backend/cli/src/auth/wellknown-command.ts | 282 + backend/cli/src/cli/cmd/auth.ts | 167 +- backend/cli/src/cli/cmd/sandbox.ts | 8 +- backend/cli/src/command/index.ts | 18 +- backend/cli/src/compute/jobs.ts | 1939 +++++- backend/cli/src/compute/modal/plan.ts | 16 +- backend/cli/src/compute/modal/volume.ts | 261 +- backend/cli/src/compute/ssh/adapter.ts | 1272 ++++ backend/cli/src/compute/ssh/plan.ts | 130 + backend/cli/src/config/config.ts | 52 +- backend/cli/src/credentials/lifecycle.ts | 256 + backend/cli/src/credentials/process-ledger.ts | 623 ++ backend/cli/src/file/index.ts | 25 +- backend/cli/src/file/publication.ts | 290 +- backend/cli/src/file/ripgrep.ts | 26 +- backend/cli/src/file/safe-io.ts | 127 + backend/cli/src/file/science.ts | 325 +- backend/cli/src/file/trash.ts | 257 + backend/cli/src/format/index.ts | 92 +- backend/cli/src/global/data-relocation.ts | 292 + backend/cli/src/global/data-root-barrier.ts | 277 + backend/cli/src/global/data-root.ts | 111 + backend/cli/src/global/index.ts | 36 +- backend/cli/src/global/windows-junction.ts | 133 + backend/cli/src/index.ts | 49 + backend/cli/src/installation/index.ts | 194 +- backend/cli/src/lsp/client.ts | 132 +- backend/cli/src/lsp/index.ts | 119 +- backend/cli/src/lsp/server.ts | 292 +- backend/cli/src/mcp/auth.ts | 52 +- backend/cli/src/mcp/group-launcher.ts | 110 + backend/cli/src/mcp/index.ts | 270 +- backend/cli/src/openscience/dotenv.ts | 112 +- backend/cli/src/openscience/index.ts | 455 +- backend/cli/src/openscience/preload-env.ts | 26 +- backend/cli/src/patch/index.ts | 22 +- backend/cli/src/permission/next.ts | 11 +- backend/cli/src/plugin/index.ts | 76 +- .../process/darwin-responsibility-launcher.ts | 224 + .../cli/src/process/darwin-responsibility.ts | 246 + backend/cli/src/process/process-identity.ts | 57 + .../cli/src/process/windows-job-launcher.ts | 161 + backend/cli/src/process/windows-job.ts | 279 + backend/cli/src/project/authority-process.ts | 539 ++ backend/cli/src/project/authority-signal.ts | 180 + backend/cli/src/project/bootstrap.ts | 154 +- backend/cli/src/project/execution.ts | 9 +- backend/cli/src/project/project.ts | 105 +- backend/cli/src/project/trust.ts | 125 +- backend/cli/src/provider/auth.ts | 11 +- backend/cli/src/provider/provider.ts | 91 +- backend/cli/src/provider/token-command.ts | 358 ++ backend/cli/src/pty/environment.ts | 22 +- backend/cli/src/pty/index.ts | 206 +- backend/cli/src/sandbox/sandbox.ts | 487 +- backend/cli/src/science/command/registry.ts | 127 +- backend/cli/src/science/connectors/http.ts | 79 +- backend/cli/src/science/kernel/interpreter.ts | 72 + backend/cli/src/science/kernel/process.ts | 135 +- backend/cli/src/science/kernel/registry.ts | 557 +- backend/cli/src/science/kernel/types.ts | 18 + .../cli/src/science/provenance/envelope.ts | 49 +- backend/cli/src/science/provenance/store.ts | 2 + backend/cli/src/server/routes/file.ts | 44 + backend/cli/src/server/routes/global.ts | 11 +- backend/cli/src/server/routes/notebook.ts | 73 +- backend/cli/src/server/routes/project.ts | 2 +- backend/cli/src/server/routes/repo.ts | 147 +- .../cli/src/server/routes/settings/compute.ts | 100 +- .../src/server/routes/settings/credentials.ts | 238 +- .../cli/src/server/routes/settings/local.ts | 261 +- .../cli/src/server/routes/settings/sandbox.ts | 11 +- .../cli/src/server/routes/settings/storage.ts | 140 +- .../cli/src/server/routes/settings/updates.ts | 70 +- backend/cli/src/server/server.ts | 34 + backend/cli/src/session/filesystem.ts | 267 +- backend/cli/src/session/index.ts | 95 +- backend/cli/src/session/instruction.ts | 3 +- backend/cli/src/session/prompt.ts | 137 +- backend/cli/src/settings/memory.ts | 2 + backend/cli/src/settings/network.ts | 493 +- backend/cli/src/settings/review.ts | 2 + backend/cli/src/storage/storage.ts | 63 + backend/cli/src/tool/apply_patch.ts | 197 +- backend/cli/src/tool/bash.ts | 149 +- backend/cli/src/tool/biology/database.ts | 5 +- backend/cli/src/tool/biology/notebook.ts | 291 +- backend/cli/src/tool/edit.ts | 22 +- backend/cli/src/tool/modal.ts | 8 +- backend/cli/src/tool/notebook.ts | 157 +- backend/cli/src/tool/read.ts | 28 +- backend/cli/src/tool/registry.ts | 42 +- backend/cli/src/tool/rkernel.ts | 96 +- backend/cli/src/tool/webfetch.ts | 146 +- backend/cli/src/tool/write.ts | 9 +- backend/cli/src/util/file-lease.ts | 125 + backend/cli/src/util/jsonstore.ts | 2 + backend/cli/src/util/log.ts | 28 +- backend/cli/test/agent/agent.test.ts | 57 +- .../test/artifact/store-multiprocess.test.ts | 162 + backend/cli/test/auth/auth.test.ts | 70 + .../cli/test/auth/wellknown-command.test.ts | 139 + .../test/compute/jobs-multiprocess.test.ts | 337 + backend/cli/test/compute/jobs.test.ts | 261 +- backend/cli/test/compute/modal-volume.test.ts | 55 +- backend/cli/test/compute/ssh-adapter.test.ts | 115 + .../cli/test/compute/ssh-integration.test.ts | 417 ++ backend/cli/test/config/agent-color.test.ts | 3 +- .../test/credentials/process-boundary.test.ts | 28 + .../test/credentials/process-ledger.test.ts | 164 + backend/cli/test/file/publication.test.ts | 129 +- backend/cli/test/file/ripgrep.test.ts | 30 + backend/cli/test/file/science-inspect.test.ts | 132 + backend/cli/test/file/trash.test.ts | 163 + backend/cli/test/fixture/authority-process.ts | 71 + .../test/fixture/authority-runtime-process.ts | 338 + .../test/fixture/dotenv-project-process.ts | 21 + .../test/fixture/kernel-built-in-setsid.ts | 109 + .../cli/test/fixture/kernel-leader-exit.ts | 109 + .../cli/test/fixture/local-runtime-process.ts | 10 + backend/cli/test/fixture/mcp-descendant.mjs | 19 + .../cli/test/fixture/ssh-compute-process.ts | 100 + backend/cli/test/fixture/windows-job.ts | 5 + backend/cli/test/global/data-root.test.ts | 233 + .../native-package-matrix.test.ts | 4 +- .../test/installation/root-isolation.test.ts | 4 +- .../test/installation/update-safety.test.ts | 86 + backend/cli/test/lsp/client.test.ts | 54 +- backend/cli/test/lsp/environment.test.ts | 64 + backend/cli/test/lsp/orphan-process.test.ts | 257 + backend/cli/test/lsp/sandbox.test.ts | 105 + backend/cli/test/lsp/trust.test.ts | 58 + backend/cli/test/mcp/inspect.test.ts | 73 +- backend/cli/test/openscience-env.test.ts | 11 + backend/cli/test/openscience-logout.test.ts | 74 + backend/cli/test/openscience/dotenv.test.ts | 90 +- .../test/openscience/sync-precedence.test.ts | 2 + .../process/darwin-responsibility.test.ts | 73 + backend/cli/test/process/windows-job.test.ts | 170 + .../project/authority-process-ledger.test.ts | 279 + .../execution-cache-revocation.test.ts | 240 + .../cli/test/project/execution-trust.test.ts | 3 +- backend/cli/test/project/policy-trust.test.ts | 74 + backend/cli/test/project/trust.test.ts | 149 +- .../cli/test/provider/managed-routing.test.ts | 12 +- .../provider/token-command-process.test.ts | 190 + .../cli/test/provider/token-command.test.ts | 114 + backend/cli/test/pty-environment.test.ts | 16 +- backend/cli/test/sandbox/sandbox.test.ts | 428 +- .../test/science/connector-ratelimit.test.ts | 114 +- backend/cli/test/science/http.test.ts | 30 +- backend/cli/test/science/kernel-lease.test.ts | 596 ++ .../test/science/kernel-process-order.test.ts | 75 + .../test/science/kernel-provenance.test.ts | 15 + .../cli/test/science/kernel-signal.test.ts | 17 + .../test/science/kernel/interpreter.test.ts | 172 + backend/cli/test/server/file-artifact.test.ts | 28 + backend/cli/test/server/notebook.test.ts | 199 +- .../server/project-selection-routes.test.ts | 69 +- .../server/session-shell-security.test.ts | 55 + .../cli/test/server/settings-compute.test.ts | 460 +- .../test/server/settings-credentials.test.ts | 155 +- .../cli/test/server/settings-local.test.ts | 94 +- .../cli/test/server/settings-sandbox.test.ts | 16 + .../cli/test/server/settings-storage.test.ts | 418 ++ .../cli/test/server/settings-updates.test.ts | 57 +- .../cli/test/session/command-shell.test.ts | 71 + .../test/session/filesystem-grants.test.ts | 52 +- backend/cli/test/session/instruction.test.ts | 32 + backend/cli/test/session/removal-ack.test.ts | 50 + backend/cli/test/settings/network.test.ts | 178 + .../storage/interprocess-authority.test.ts | 162 + backend/cli/test/tool/apply_patch.test.ts | 135 +- .../tool/biology-notebook-concurrency.test.ts | 71 + .../tool/command-runtime-multiprocess.test.ts | 163 + backend/cli/test/tool/command-runtime.test.ts | 158 +- backend/cli/test/tool/modal.test.ts | 16 + backend/cli/test/tool/named-kernels.test.ts | 3 + backend/cli/test/tool/plan-mode.test.ts | 5 +- backend/cli/test/tool/read.test.ts | 31 + backend/cli/test/tool/registry.test.ts | 8 + .../cli/test/tool/webfetch-network.test.ts | 29 + backend/cli/test/tool/write-safety.test.ts | 113 + backend/cli/test/util/file-lease.test.ts | 32 + bun.lock | 3 + frontend/ui/package.json | 5 +- frontend/ui/src/components/basic-tool.css | 4 - frontend/ui/src/components/button.css | 26 +- frontend/ui/src/components/checkbox.css | 8 + frontend/ui/src/components/collapsible.css | 2 +- frontend/ui/src/components/dialog.tsx | 85 +- frontend/ui/src/components/dropdown-menu.css | 18 +- frontend/ui/src/components/hover-card.css | 7 +- frontend/ui/src/components/icon-button.css | 32 +- frontend/ui/src/components/icon-button.tsx | 5 +- .../ui/src/components/icon-system.test.ts | 87 + frontend/ui/src/components/icon.css | 44 +- frontend/ui/src/components/icon.tsx | 103 +- .../ui/src/components/iconoir-registry.ts | 345 + frontend/ui/src/components/keybind.css | 2 +- frontend/ui/src/components/line-comment.css | 4 +- frontend/ui/src/components/markdown.css | 6 +- frontend/ui/src/components/markdown.tsx | 17 +- .../components/message-part-artifact.test.ts | 5 +- .../components/message-part-notebook.test.ts | 10 +- frontend/ui/src/components/message-part.css | 160 +- frontend/ui/src/components/message-part.tsx | 92 +- frontend/ui/src/components/notebook-cell.css | 4 +- frontend/ui/src/components/popover.css | 5 +- frontend/ui/src/components/select.css | 37 +- frontend/ui/src/components/session-review.css | 4 +- frontend/ui/src/components/session-turn.css | 30 +- frontend/ui/src/components/session-turn.tsx | 10 +- frontend/ui/src/components/switch.css | 12 +- frontend/ui/src/components/tabs.css | 6 +- frontend/ui/src/components/text-field.css | 6 + frontend/ui/src/components/toast.css | 13 +- .../ui/src/components/tool-display.test.ts | 13 + frontend/ui/src/components/tool-display.ts | 6 + .../components/user-message-layout.test.ts | 44 + frontend/ui/src/context/dialog.tsx | 59 +- .../ui/src/context/marked-loading.test.ts | 53 + frontend/ui/src/context/marked.tsx | 769 +-- frontend/ui/src/i18n/en.ts | 2 +- frontend/ui/src/pierre/index.ts | 11 +- frontend/ui/src/styles/animations.css | 102 +- frontend/ui/src/styles/base.css | 6 +- .../ui/src/styles/motion-contract.test.ts | 66 + .../ui/src/styles/radius-contract.test.ts | 53 + frontend/ui/src/styles/tailwind/index.css | 2 + frontend/ui/src/styles/theme.css | 309 +- .../ui/src/styles/typography-contract.test.ts | 36 + frontend/ui/src/styles/utilities.css | 9 +- frontend/ui/src/theme/context.tsx | 58 +- .../ui/src/theme/openscience-theme.test.ts | 103 +- frontend/ui/src/theme/themes/openscience.json | 172 +- frontend/workspace/e2e/home-projects.spec.ts | 5 +- frontend/workspace/e2e/home.spec.ts | 6 +- frontend/workspace/e2e/palette.spec.ts | 42 +- .../workspace/e2e/research-launchpad.spec.ts | 12 - .../workspace/e2e/server-recovery.spec.ts | 2 +- frontend/workspace/e2e/session.spec.ts | 41 +- .../e2e/sidebar-session-links.spec.ts | 34 +- frontend/workspace/e2e/sidebar.spec.ts | 11 +- .../workspace/e2e/titlebar-history.spec.ts | 10 +- frontend/workspace/e2e/utils.ts | 10 +- .../e2e/workspace-consistency.spec.ts | 3 +- frontend/workspace/index.html | 3 +- .../public/openscience-theme-preload.js | 25 +- .../workspace/script/release-version.test.ts | 2 +- frontend/workspace/src/app-provider.test.ts | 6 + frontend/workspace/src/app.css | 53 + frontend/workspace/src/app.tsx | 50 +- .../src/artifacts/ArtifactInspector.tsx | 126 +- .../src/artifacts/StoredArtifactView.tsx | 30 +- .../workspace/src/artifacts/inspector.test.ts | 9 + frontend/workspace/src/artifacts/model.ts | 2 - .../workspace/src/artifacts/resource.test.ts | 2 + frontend/workspace/src/artifacts/resource.ts | 15 +- .../atlas/AtlasCanvas.accessibility.test.ts | 20 + frontend/workspace/src/atlas/AtlasCanvas.tsx | 50 +- .../workspace/src/atlas/CommandCard.test.tsx | 7 +- frontend/workspace/src/atlas/CommandCard.tsx | 22 +- .../workspace/src/atlas/CommandPalette.css | 467 ++ .../src/atlas/CommandPalette.test.ts | 169 +- .../workspace/src/atlas/CommandPalette.tsx | 675 +- .../workspace/src/atlas/ComputeJobs.test.ts | 17 +- frontend/workspace/src/atlas/ComputeJobs.tsx | 156 +- .../workspace/src/atlas/ComputeJobsAPI.ts | 54 +- .../workspace/src/atlas/ComputeSurface.css | 312 +- .../src/atlas/ComputeSurface.test.ts | 10 + .../src/atlas/DisconnectedPanel.test.ts | 14 + .../workspace/src/atlas/DisconnectedPanel.tsx | 56 +- .../workspace/src/atlas/DispatchPreview.tsx | 6 +- frontend/workspace/src/atlas/FdaBanner.css | 2 +- frontend/workspace/src/atlas/FileExplorer.css | 132 + .../workspace/src/atlas/FileExplorer.test.ts | 6 +- frontend/workspace/src/atlas/FileExplorer.tsx | 180 +- frontend/workspace/src/atlas/FilePreview.css | 180 +- frontend/workspace/src/atlas/FilePreview.tsx | 73 +- .../src/atlas/FilePreviewMarkdown.test.ts | 54 + .../src/atlas/FilePreviewMarkdown.ts | 52 + frontend/workspace/src/atlas/FileToolbar.tsx | 37 +- .../workspace/src/atlas/FilesPane.test.ts | 267 +- frontend/workspace/src/atlas/FilesPane.tsx | 406 +- frontend/workspace/src/atlas/FolderPicker.css | 692 ++ .../workspace/src/atlas/FolderPicker.test.ts | 87 + frontend/workspace/src/atlas/FolderPicker.tsx | 760 +-- .../workspace/src/atlas/HelpOverlay.test.ts | 34 + frontend/workspace/src/atlas/HelpOverlay.tsx | 98 +- frontend/workspace/src/atlas/HostStrip.css | 71 +- .../workspace/src/atlas/HostStrip.test.ts | 19 +- frontend/workspace/src/atlas/HostStrip.tsx | 17 +- .../workspace/src/atlas/KernelCard.test.tsx | 21 +- frontend/workspace/src/atlas/KernelCard.tsx | 40 +- .../src/atlas/KernelPanel.poll.test.ts | 8 +- .../workspace/src/atlas/KernelPanel.test.ts | 24 +- frontend/workspace/src/atlas/KernelPanel.tsx | 135 +- .../src/atlas/OpenScienceFileTree.tsx | 2 +- .../workspace/src/atlas/ProjectRightPane.tsx | 13 + .../src/atlas/ProjectWorkspaceFrame.css | 20 + .../src/atlas/ProjectWorkspaceFrame.tsx | 16 + .../src/atlas/RemoteJobCard.test.tsx | 27 +- .../workspace/src/atlas/RemoteJobCard.tsx | 55 +- frontend/workspace/src/atlas/RightPane.tsx | 282 +- .../src/atlas/SessionTraceSurface.css | 122 +- .../workspace/src/atlas/SkillsBrowser.tsx | 41 +- frontend/workspace/src/atlas/SkillsPage.tsx | 551 +- .../workspace/src/atlas/TerminalSurface.css | 426 ++ .../workspace/src/atlas/TerminalSurface.tsx | 57 +- frontend/workspace/src/atlas/Toast.test.ts | 18 + frontend/workspace/src/atlas/Toast.tsx | 147 +- frontend/workspace/src/atlas/Wordmark.tsx | 44 +- .../src/atlas/compute-surface-style.test.ts | 33 +- frontend/workspace/src/atlas/dialogs.tsx | 173 +- .../src/atlas/execution-authority.test.ts | 41 + .../src/atlas/execution-authority.ts | 24 + .../workspace/src/atlas/file-drafts.test.ts | 41 + frontend/workspace/src/atlas/file-drafts.ts | 45 + .../src/atlas/file-preview-render.test.ts | 26 +- .../src/atlas/file-preview-surface.test.ts | 29 +- .../src/atlas/file-session-access.test.ts | 13 +- .../workspace/src/atlas/file-viewer.test.ts | 31 +- frontend/workspace/src/atlas/file-viewer.ts | 9 +- .../src/atlas/files/ArtifactCard.test.ts | 16 +- .../src/atlas/files/ArtifactCard.tsx | 35 +- .../src/atlas/files/ArtifactGrid.test.ts | 67 +- .../src/atlas/files/ArtifactGrid.tsx | 208 +- .../src/atlas/files/FileTable.test.ts | 35 +- .../workspace/src/atlas/files/FileTable.tsx | 45 +- .../src/atlas/files/FileTabs.test.ts | 133 - .../workspace/src/atlas/files/FileTabs.tsx | 73 - .../workspace/src/atlas/files/FilesPane.css | 779 ++- .../src/atlas/files/RemoteFileView.tsx | 8 +- .../src/atlas/files/SourceMenu.test.ts | 90 +- .../workspace/src/atlas/files/SourceMenu.tsx | 120 +- .../workspace/src/atlas/files/TrashList.tsx | 37 +- .../atlas/files/artifact-boundaries.test.ts | 8 +- .../src/atlas/files/artifact-styles.test.ts | 66 +- .../workspace/src/atlas/files/file-items.css | 236 + .../src/atlas/files/file-items.test.ts | 25 + .../workspace/src/atlas/files/sources.test.ts | 58 +- frontend/workspace/src/atlas/files/sources.ts | 63 +- .../src/atlas/project-search.test.ts | 23 + .../workspace/src/atlas/project-search.ts | 11 + .../project-workspace-lifecycle.fixture.tsx | 36 + .../atlas/project-workspace-lifecycle.test.ts | 54 + .../src/atlas/right-pane-artifact.test.ts | 14 +- .../src/atlas/right-pane-files.test.ts | 24 +- .../src/atlas/right-pane-layout.test.ts | 15 +- .../workspace/src/atlas/right-pane-layout.ts | 20 +- .../src/atlas/right-pane-surface.test.ts | 55 +- .../workspace/src/atlas/right-pane-tabs.css | 337 + .../workspace/src/atlas/shared/AgentIcon.tsx | 15 +- .../workspace/src/atlas/shared/Icon.test.ts | 40 + frontend/workspace/src/atlas/shared/Icon.tsx | 92 +- .../workspace/src/atlas/shared/StatusDot.tsx | 27 +- .../workspace/src/atlas/skill-permissions.ts | 91 + frontend/workspace/src/atlas/skills-page.css | 704 +++ .../workspace/src/atlas/skills-page.test.ts | 111 + .../src/atlas/skills-permission.test.ts | 81 + .../src/atlas/store/sessionTabs.test.ts | 30 +- frontend/workspace/src/atlas/store/ui.test.ts | 21 +- frontend/workspace/src/atlas/store/ui.ts | 26 +- .../src/atlas/use-execution-authority.ts | 21 +- .../src/atlas/use-kernel-list.test.ts | 16 +- .../workspace/src/atlas/useGlobalKeys.test.ts | 8 + frontend/workspace/src/atlas/useGlobalKeys.ts | 1 + .../src/atlas/workspace-shell-polish.test.ts | 55 + .../components/chat-keyboard-focus.test.ts | 24 + .../workspace/src/components/chat-surface.css | 419 ++ .../src/components/chat-surface.test.ts | 115 + .../src/components/dialog-create-project.css | 345 + .../components/dialog-create-project.test.tsx | 15 +- .../src/components/dialog-create-project.tsx | 81 +- .../src/components/dialog-select-model.css | 62 +- .../src/components/dialog-select-model.tsx | 2 +- .../src/components/dialog-select-server.css | 302 + .../components/dialog-select-server.test.ts | 65 + .../src/components/dialog-select-server.tsx | 362 +- .../src/components/dialog-settings.test.ts | 248 +- .../src/components/dialog-settings.tsx | 1575 ++++- .../interaction-radius-contract.test.ts | 65 + .../components/mobile-compose-model.test.ts | 4 +- .../src/components/model-quick.test.ts | 47 + .../workspace/src/components/model-quick.ts | 70 + .../src/components/model-settings-popover.css | 204 +- .../components/model-settings-popover.test.ts | 16 + .../src/components/model-settings-popover.tsx | 282 +- .../src/components/model-surface.test.ts | 14 +- .../src/components/prompt-attachment.test.ts | 14 +- .../src/components/prompt-attachment.ts | 15 + .../components/prompt-input-surface.test.ts | 63 +- .../workspace/src/components/prompt-input.css | 556 +- .../workspace/src/components/prompt-input.tsx | 265 +- .../src/components/prompt-placeholder.test.ts | 47 + .../session/research-launchpad.test.ts | 127 - .../components/session/research-launchpad.ts | 275 - .../session/review-workflow.test.ts | 15 +- .../components/session/session-new-view.tsx | 3 - .../src/components/settings-general.css | 34 + .../src/components/settings-general.tsx | 284 +- .../components/settings-permissions.test.ts | 57 + .../src/components/settings-permissions.tsx | 129 +- .../components/settings/CodexConnection.tsx | 77 +- .../src/components/settings/Compute.test.ts | 31 + .../src/components/settings/Compute.tsx | 775 ++- .../components/settings/Connectors.test.ts | 74 + .../src/components/settings/Connectors.tsx | 844 ++- .../settings/CredentialServices.tsx | 302 +- .../src/components/settings/Credentials.tsx | 19 +- .../src/components/settings/General.test.ts | 31 + .../src/components/settings/General.tsx | 323 +- .../settings/ManagedInference.test.ts | 60 +- .../components/settings/ManagedInference.tsx | 173 +- .../src/components/settings/Models.tsx | 482 +- .../settings/ModelsComputeUi.test.ts | 90 + .../src/components/settings/Network.test.ts | 53 +- .../src/components/settings/Network.tsx | 409 +- .../src/components/settings/Permissions.tsx | 275 +- .../src/components/settings/ProviderKeys.tsx | 151 +- .../components/settings/ProviderLogo.test.ts | 6 + .../src/components/settings/ProviderLogo.tsx | 27 +- .../src/components/settings/Sandbox.test.ts | 63 + .../src/components/settings/Sandbox.tsx | 569 +- .../src/components/settings/Skills.test.ts | 75 +- .../src/components/settings/Skills.tsx | 44 +- .../components/settings/Specialists.test.ts | 149 +- .../src/components/settings/Specialists.tsx | 614 +- .../src/components/settings/Storage.tsx | 397 +- .../src/components/settings/_shared.tsx | 158 +- .../src/components/settings/connector-form.ts | 180 + .../settings/connectors-style.test.ts | 38 + .../src/components/settings/connectors.css | 564 ++ .../settings/custom-credential.test.ts | 19 + .../components/settings/custom-credential.ts | 19 + .../destructive-confirmations.test.ts | 29 + .../src/components/settings/models.css | 534 ++ .../settings/network-domain.test.ts | 28 + .../src/components/settings/network-domain.ts | 43 + .../components/settings/network-endpoint.ts | 3 + .../components/settings/network-write.test.ts | 69 + .../src/components/settings/network-write.ts | 39 + .../components/settings/panel-layout.test.ts | 66 + .../settings/panel-stack.fixture.tsx | 29 + .../components/settings/panel-stack.test.tsx | 75 + .../src/components/settings/panel-stack.tsx | 73 + .../settings/permission-defaults.ts | 80 + .../components/settings/preference-panels.css | 657 ++ .../settings/preference-panels.test.ts | 64 + .../components/settings/preference-write.ts | 11 + .../settings/registry-contract.test.ts | 54 + .../src/components/settings/registry.ts | 55 +- .../src/components/settings/sandbox.css | 293 + .../src/components/settings/skills.css | 290 + .../components/settings/specialist-catalog.ts | 33 + .../src/components/settings/specialists.css | 427 ++ .../settings/startup-update.test.ts | 98 + .../components/settings/startup-update.tsx | 83 + .../components/settings/theme-lock.test.ts | 41 + .../components/settings/truth-pass.test.ts | 66 +- .../src/components/terminal-error.test.ts | 6 +- .../src/components/terminal-error.ts | 2 +- .../src/components/terminal-surface.test.ts | 28 +- frontend/workspace/src/context/command.tsx | 24 - frontend/workspace/src/context/file.tsx | 24 +- frontend/workspace/src/context/models.tsx | 4 +- .../workspace/src/context/notification.tsx | 16 +- frontend/workspace/src/context/permission.tsx | 42 +- frontend/workspace/src/context/platform.tsx | 2 +- frontend/workspace/src/context/settings.tsx | 34 +- frontend/workspace/src/context/terminal.tsx | 161 +- frontend/workspace/src/data/DataTableView.css | 523 ++ frontend/workspace/src/data/DataTableView.tsx | 529 +- frontend/workspace/src/data/table.test.ts | 31 + frontend/workspace/src/entry.tsx | 5 +- frontend/workspace/src/i18n/ar.ts | 28 +- frontend/workspace/src/i18n/br.ts | 28 +- frontend/workspace/src/i18n/da.ts | 28 +- frontend/workspace/src/i18n/de.ts | 28 +- frontend/workspace/src/i18n/en-casing.test.ts | 30 + frontend/workspace/src/i18n/en.ts | 1020 ++- frontend/workspace/src/i18n/es.ts | 28 +- frontend/workspace/src/i18n/fr.ts | 28 +- frontend/workspace/src/i18n/ja.ts | 28 +- frontend/workspace/src/i18n/ko.ts | 28 +- frontend/workspace/src/i18n/no.ts | 28 +- frontend/workspace/src/i18n/pl.ts | 28 +- frontend/workspace/src/i18n/ru.ts | 28 +- frontend/workspace/src/i18n/th.ts | 28 +- frontend/workspace/src/i18n/zh.ts | 28 +- frontend/workspace/src/i18n/zht.ts | 28 +- frontend/workspace/src/index.css | 16 +- .../src/manuscript/ManuscriptWorkbench.tsx | 10 +- .../workspace/src/notebook/NotebookView.tsx | 6 +- .../workspace/src/notebook/runtime.test.ts | 2 + frontend/workspace/src/notebook/runtime.ts | 13 +- .../workspace/src/pages/directory-layout.tsx | 23 +- frontend/workspace/src/pages/error.tsx | 43 +- .../workspace/src/pages/home-launcher.tsx | 177 - .../workspace/src/pages/home-projects.test.ts | 6 +- frontend/workspace/src/pages/home-projects.ts | 7 +- .../workspace/src/pages/home-workbench.css | 573 +- .../src/pages/home-workbench.test.tsx | 65 +- .../workspace/src/pages/home-workbench.tsx | 278 +- frontend/workspace/src/pages/home.css | 502 -- frontend/workspace/src/pages/home.test.tsx | 158 - frontend/workspace/src/pages/home.tsx | 42 +- .../src/pages/project-navigation.test.ts | 36 +- .../src/pages/session-availability.test.ts | 16 + .../src/pages/session-availability.ts | 22 + .../workspace/src/pages/session-header.css | 88 + .../workspace/src/pages/session-loader.ts | 17 + .../workspace/src/pages/session-shell.test.ts | 123 +- .../src/pages/session-sidebar-action.test.tsx | 7 +- .../src/pages/session-sidebar-action.tsx | 4 +- .../src/pages/session-sidebar-size.test.ts | 1 + .../src/pages/session-sidebar-size.ts | 2 +- .../src/pages/session-sidebar-type.test.ts | 39 +- .../workspace/src/pages/session-sidebar.css | 654 ++ .../src/pages/session-tab-navigation.test.ts | 16 + .../src/pages/session-tab-navigation.ts | 9 + frontend/workspace/src/pages/session-tabs.css | 248 + .../workspace/src/pages/session-tabs.test.tsx | 168 + frontend/workspace/src/pages/session-tabs.tsx | 288 + frontend/workspace/src/pages/session.tsx | 880 +-- .../src/science/formats/BinaryScienceView.tsx | 39 +- .../science/formats/ScientificDataView.tsx | 111 +- .../src/science/formats/biological.test.ts | 17 + .../science/renderers/documents/PdfViewer.css | 269 + .../renderers/documents/PdfViewer.test.ts | 44 + .../science/renderers/documents/PdfViewer.tsx | 461 +- .../workspace/src/science/tool-renderer.tsx | 2 +- frontend/workspace/src/styles/atlas.css | 5589 +---------------- .../src/styles/design-foundation.test.ts | 172 + .../src/styles/live-surface-edges.test.ts | 47 + frontend/workspace/src/styles/tokens.test.ts | 24 +- frontend/workspace/src/styles/tokens.ts | 13 +- .../src/styles/typography-drift.test.ts | 32 + .../src/styles/typography-tokens.test.ts | 14 + .../workspace-seamless-contract.test.ts | 58 + .../src/utils/markdown-assets.test.ts | 33 +- .../workspace/src/utils/markdown-assets.ts | 31 +- frontend/workspace/src/utils/sound.test.ts | 89 + frontend/workspace/src/utils/sound.ts | 45 +- package.json | 4 +- 552 files changed, 53349 insertions(+), 19752 deletions(-) delete mode 100644 .openscience/agent/docs.md create mode 100644 backend/cli/src/auth/wellknown-command.ts create mode 100644 backend/cli/src/compute/ssh/adapter.ts create mode 100644 backend/cli/src/compute/ssh/plan.ts create mode 100644 backend/cli/src/credentials/lifecycle.ts create mode 100644 backend/cli/src/credentials/process-ledger.ts create mode 100644 backend/cli/src/file/safe-io.ts create mode 100644 backend/cli/src/file/trash.ts create mode 100644 backend/cli/src/global/data-relocation.ts create mode 100644 backend/cli/src/global/data-root-barrier.ts create mode 100644 backend/cli/src/global/data-root.ts create mode 100644 backend/cli/src/global/windows-junction.ts create mode 100644 backend/cli/src/mcp/group-launcher.ts create mode 100644 backend/cli/src/process/darwin-responsibility-launcher.ts create mode 100644 backend/cli/src/process/darwin-responsibility.ts create mode 100644 backend/cli/src/process/process-identity.ts create mode 100644 backend/cli/src/process/windows-job-launcher.ts create mode 100644 backend/cli/src/process/windows-job.ts create mode 100644 backend/cli/src/project/authority-process.ts create mode 100644 backend/cli/src/project/authority-signal.ts create mode 100644 backend/cli/src/provider/token-command.ts create mode 100644 backend/cli/src/science/kernel/interpreter.ts create mode 100644 backend/cli/src/util/file-lease.ts create mode 100644 backend/cli/test/artifact/store-multiprocess.test.ts create mode 100644 backend/cli/test/auth/wellknown-command.test.ts create mode 100644 backend/cli/test/compute/jobs-multiprocess.test.ts create mode 100644 backend/cli/test/compute/ssh-adapter.test.ts create mode 100644 backend/cli/test/compute/ssh-integration.test.ts create mode 100644 backend/cli/test/credentials/process-boundary.test.ts create mode 100644 backend/cli/test/credentials/process-ledger.test.ts create mode 100644 backend/cli/test/file/ripgrep.test.ts create mode 100644 backend/cli/test/file/trash.test.ts create mode 100644 backend/cli/test/fixture/authority-process.ts create mode 100644 backend/cli/test/fixture/authority-runtime-process.ts create mode 100644 backend/cli/test/fixture/dotenv-project-process.ts create mode 100644 backend/cli/test/fixture/kernel-built-in-setsid.ts create mode 100644 backend/cli/test/fixture/kernel-leader-exit.ts create mode 100644 backend/cli/test/fixture/local-runtime-process.ts create mode 100644 backend/cli/test/fixture/mcp-descendant.mjs create mode 100644 backend/cli/test/fixture/ssh-compute-process.ts create mode 100644 backend/cli/test/fixture/windows-job.ts create mode 100644 backend/cli/test/global/data-root.test.ts create mode 100644 backend/cli/test/installation/update-safety.test.ts create mode 100644 backend/cli/test/lsp/environment.test.ts create mode 100644 backend/cli/test/lsp/orphan-process.test.ts create mode 100644 backend/cli/test/lsp/sandbox.test.ts create mode 100644 backend/cli/test/lsp/trust.test.ts create mode 100644 backend/cli/test/process/darwin-responsibility.test.ts create mode 100644 backend/cli/test/process/windows-job.test.ts create mode 100644 backend/cli/test/project/authority-process-ledger.test.ts create mode 100644 backend/cli/test/project/execution-cache-revocation.test.ts create mode 100644 backend/cli/test/project/policy-trust.test.ts create mode 100644 backend/cli/test/provider/token-command-process.test.ts create mode 100644 backend/cli/test/science/kernel-lease.test.ts create mode 100644 backend/cli/test/science/kernel-process-order.test.ts create mode 100644 backend/cli/test/science/kernel/interpreter.test.ts create mode 100644 backend/cli/test/server/session-shell-security.test.ts create mode 100644 backend/cli/test/server/settings-storage.test.ts create mode 100644 backend/cli/test/session/command-shell.test.ts create mode 100644 backend/cli/test/session/removal-ack.test.ts create mode 100644 backend/cli/test/storage/interprocess-authority.test.ts create mode 100644 backend/cli/test/tool/biology-notebook-concurrency.test.ts create mode 100644 backend/cli/test/tool/command-runtime-multiprocess.test.ts create mode 100644 backend/cli/test/tool/write-safety.test.ts create mode 100644 backend/cli/test/util/file-lease.test.ts create mode 100644 frontend/ui/src/components/icon-system.test.ts create mode 100644 frontend/ui/src/components/iconoir-registry.ts create mode 100644 frontend/ui/src/components/user-message-layout.test.ts create mode 100644 frontend/ui/src/context/marked-loading.test.ts create mode 100644 frontend/ui/src/styles/motion-contract.test.ts create mode 100644 frontend/ui/src/styles/radius-contract.test.ts create mode 100644 frontend/ui/src/styles/typography-contract.test.ts delete mode 100644 frontend/workspace/e2e/research-launchpad.spec.ts create mode 100644 frontend/workspace/src/app.css create mode 100644 frontend/workspace/src/atlas/AtlasCanvas.accessibility.test.ts create mode 100644 frontend/workspace/src/atlas/CommandPalette.css create mode 100644 frontend/workspace/src/atlas/DisconnectedPanel.test.ts create mode 100644 frontend/workspace/src/atlas/FileExplorer.css create mode 100644 frontend/workspace/src/atlas/FilePreviewMarkdown.test.ts create mode 100644 frontend/workspace/src/atlas/FilePreviewMarkdown.ts create mode 100644 frontend/workspace/src/atlas/FolderPicker.css create mode 100644 frontend/workspace/src/atlas/FolderPicker.test.ts create mode 100644 frontend/workspace/src/atlas/HelpOverlay.test.ts create mode 100644 frontend/workspace/src/atlas/ProjectRightPane.tsx create mode 100644 frontend/workspace/src/atlas/ProjectWorkspaceFrame.css create mode 100644 frontend/workspace/src/atlas/ProjectWorkspaceFrame.tsx create mode 100644 frontend/workspace/src/atlas/TerminalSurface.css create mode 100644 frontend/workspace/src/atlas/Toast.test.ts create mode 100644 frontend/workspace/src/atlas/file-drafts.test.ts create mode 100644 frontend/workspace/src/atlas/file-drafts.ts delete mode 100644 frontend/workspace/src/atlas/files/FileTabs.test.ts delete mode 100644 frontend/workspace/src/atlas/files/FileTabs.tsx create mode 100644 frontend/workspace/src/atlas/files/file-items.css create mode 100644 frontend/workspace/src/atlas/files/file-items.test.ts create mode 100644 frontend/workspace/src/atlas/project-search.test.ts create mode 100644 frontend/workspace/src/atlas/project-search.ts create mode 100644 frontend/workspace/src/atlas/project-workspace-lifecycle.fixture.tsx create mode 100644 frontend/workspace/src/atlas/project-workspace-lifecycle.test.ts create mode 100644 frontend/workspace/src/atlas/right-pane-tabs.css create mode 100644 frontend/workspace/src/atlas/shared/Icon.test.ts create mode 100644 frontend/workspace/src/atlas/skill-permissions.ts create mode 100644 frontend/workspace/src/atlas/skills-page.css create mode 100644 frontend/workspace/src/atlas/skills-permission.test.ts create mode 100644 frontend/workspace/src/atlas/workspace-shell-polish.test.ts create mode 100644 frontend/workspace/src/components/chat-keyboard-focus.test.ts create mode 100644 frontend/workspace/src/components/chat-surface.css create mode 100644 frontend/workspace/src/components/chat-surface.test.ts create mode 100644 frontend/workspace/src/components/dialog-create-project.css create mode 100644 frontend/workspace/src/components/dialog-select-server.css create mode 100644 frontend/workspace/src/components/dialog-select-server.test.ts create mode 100644 frontend/workspace/src/components/interaction-radius-contract.test.ts create mode 100644 frontend/workspace/src/components/model-quick.test.ts create mode 100644 frontend/workspace/src/components/model-quick.ts create mode 100644 frontend/workspace/src/components/prompt-placeholder.test.ts delete mode 100644 frontend/workspace/src/components/session/research-launchpad.test.ts delete mode 100644 frontend/workspace/src/components/session/research-launchpad.ts delete mode 100644 frontend/workspace/src/components/session/session-new-view.tsx create mode 100644 frontend/workspace/src/components/settings-general.css create mode 100644 frontend/workspace/src/components/settings-permissions.test.ts create mode 100644 frontend/workspace/src/components/settings/Compute.test.ts create mode 100644 frontend/workspace/src/components/settings/Connectors.test.ts create mode 100644 frontend/workspace/src/components/settings/General.test.ts create mode 100644 frontend/workspace/src/components/settings/ModelsComputeUi.test.ts create mode 100644 frontend/workspace/src/components/settings/Sandbox.test.ts create mode 100644 frontend/workspace/src/components/settings/connector-form.ts create mode 100644 frontend/workspace/src/components/settings/connectors-style.test.ts create mode 100644 frontend/workspace/src/components/settings/connectors.css create mode 100644 frontend/workspace/src/components/settings/custom-credential.test.ts create mode 100644 frontend/workspace/src/components/settings/custom-credential.ts create mode 100644 frontend/workspace/src/components/settings/destructive-confirmations.test.ts create mode 100644 frontend/workspace/src/components/settings/models.css create mode 100644 frontend/workspace/src/components/settings/network-domain.test.ts create mode 100644 frontend/workspace/src/components/settings/network-domain.ts create mode 100644 frontend/workspace/src/components/settings/network-endpoint.ts create mode 100644 frontend/workspace/src/components/settings/network-write.test.ts create mode 100644 frontend/workspace/src/components/settings/network-write.ts create mode 100644 frontend/workspace/src/components/settings/panel-layout.test.ts create mode 100644 frontend/workspace/src/components/settings/panel-stack.fixture.tsx create mode 100644 frontend/workspace/src/components/settings/panel-stack.test.tsx create mode 100644 frontend/workspace/src/components/settings/panel-stack.tsx create mode 100644 frontend/workspace/src/components/settings/permission-defaults.ts create mode 100644 frontend/workspace/src/components/settings/preference-panels.css create mode 100644 frontend/workspace/src/components/settings/preference-panels.test.ts create mode 100644 frontend/workspace/src/components/settings/preference-write.ts create mode 100644 frontend/workspace/src/components/settings/registry-contract.test.ts create mode 100644 frontend/workspace/src/components/settings/sandbox.css create mode 100644 frontend/workspace/src/components/settings/skills.css create mode 100644 frontend/workspace/src/components/settings/specialists.css create mode 100644 frontend/workspace/src/components/settings/startup-update.test.ts create mode 100644 frontend/workspace/src/components/settings/startup-update.tsx create mode 100644 frontend/workspace/src/components/settings/theme-lock.test.ts create mode 100644 frontend/workspace/src/data/DataTableView.css create mode 100644 frontend/workspace/src/i18n/en-casing.test.ts delete mode 100644 frontend/workspace/src/pages/home-launcher.tsx delete mode 100644 frontend/workspace/src/pages/home.css delete mode 100644 frontend/workspace/src/pages/home.test.tsx create mode 100644 frontend/workspace/src/pages/session-availability.test.ts create mode 100644 frontend/workspace/src/pages/session-availability.ts create mode 100644 frontend/workspace/src/pages/session-header.css create mode 100644 frontend/workspace/src/pages/session-loader.ts create mode 100644 frontend/workspace/src/pages/session-sidebar.css create mode 100644 frontend/workspace/src/pages/session-tab-navigation.test.ts create mode 100644 frontend/workspace/src/pages/session-tab-navigation.ts create mode 100644 frontend/workspace/src/pages/session-tabs.css create mode 100644 frontend/workspace/src/pages/session-tabs.test.tsx create mode 100644 frontend/workspace/src/pages/session-tabs.tsx create mode 100644 frontend/workspace/src/science/renderers/documents/PdfViewer.css create mode 100644 frontend/workspace/src/science/renderers/documents/PdfViewer.test.ts create mode 100644 frontend/workspace/src/styles/design-foundation.test.ts create mode 100644 frontend/workspace/src/styles/live-surface-edges.test.ts create mode 100644 frontend/workspace/src/styles/typography-drift.test.ts create mode 100644 frontend/workspace/src/styles/typography-tokens.test.ts create mode 100644 frontend/workspace/src/styles/workspace-seamless-contract.test.ts create mode 100644 frontend/workspace/src/utils/sound.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 044d02f4..a8117697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,40 @@ jobs: shell: bash working-directory: backend/cli + # Windows Job Objects are the runtime ownership boundary for terminals, + # commands, compute, kernels, LSPs, and local MCP servers. The source-level + # structure contracts run everywhere; this leg exercises the real Kernel32 + # handles, descendant inheritance, named-job reopen, and verified teardown. + windows-runtime: + name: Windows runtime ownership + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-bun + - run: bun test test/process/windows-job.test.ts test/global/data-root.test.ts + shell: bash + working-directory: backend/cli + + # macOS responsibility IDs are the kernel-backed ownership boundary for + # processes that setsid/double-fork away from their original PID and process + # group. Exercise the native private ABI and both durable ledgers on an + # actual macOS runner; source-contract tests on Linux cannot prove teardown. + macos-runtime: + name: macOS runtime ownership + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-bun + - run: >- + bun test + test/process/darwin-responsibility.test.ts + test/credentials/process-ledger.test.ts + test/project/authority-process-ledger.test.ts + shell: bash + working-directory: backend/cli + test: name: Test runs-on: ubuntu-latest @@ -66,10 +100,10 @@ jobs: git config --global user.email "ci@openscience.dev" git config --global user.name "OpenScience CI" git config --global init.defaultBranch main - - name: Install and verify Linux sandbox + - name: Install and verify Linux sandbox and SSH fixture run: | sudo apt-get update - sudo apt-get install --yes bubblewrap + sudo apt-get install --yes bubblewrap openssh-server # Ubuntu 24.04's host-wide AppArmor policy blocks unprivileged user # namespaces on the hosted runner before bubblewrap can apply our # stricter per-process profile. This runner is disposable; enable @@ -78,6 +112,11 @@ jobs: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns fi bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid --die-with-parent -- true + sudo install -d -m 0755 /run/sshd + test -x /usr/sbin/sshd + - name: Exercise real OpenSSH dispatch and recovery + run: bun test test/compute/ssh-integration.test.ts + working-directory: backend/cli - name: Build embedded web assets for server tests run: | bun run --cwd frontend/workspace build diff --git a/.openscience/agent/docs.md b/.openscience/agent/docs.md deleted file mode 100644 index db5228d5..00000000 --- a/.openscience/agent/docs.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: ALWAYS use this when writing docs -color: "#38A3EE" ---- - -You are an expert technical documentation writer - -You are not verbose - -Use a relaxed and friendly tone - -The title of the page should be a word or a 2-3 word phrase - -The description should be one short line, should not start with "The", should -avoid repeating the title of the page, should be 5-10 words long - -Chunks of text should not be more than 2 sentences long - -Each section is separated by a divider of 3 dashes - -The section titles are short with only the first letter of the word capitalized - -The section titles are in the imperative mood - -The section titles should not repeat the term used in the page title, for -example, if the page title is "Models", avoid using a section title like "Add -new models". This might be unavoidable in some cases, but try to avoid it. - -Check out the /frontend/docs/src/content/docs/index.mdx as an example. - -For JS or TS code snippets remove trailing semicolons and any trailing commas -that might not be needed. - -If you are making a commit prefix the commit message with `docs:` diff --git a/backend/cli/package.json b/backend/cli/package.json index e11ddf14..c072b389 100644 --- a/backend/cli/package.json +++ b/backend/cli/package.json @@ -11,7 +11,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test --timeout 15000", "build": "bun run script/build.ts", - "dev": "bun run --conditions=browser ./src/index.ts" + "dev": "bun --no-env-file run --conditions=browser ./src/index.ts" }, "bin": { "openscience": "./bin/openscience" diff --git a/backend/cli/src/agent/agent.ts b/backend/cli/src/agent/agent.ts index 8cf85d06..90e29829 100644 --- a/backend/cli/src/agent/agent.ts +++ b/backend/cli/src/agent/agent.ts @@ -21,6 +21,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" +import { State } from "@/project/state" export namespace Agent { export const Info = z @@ -49,8 +50,8 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + const compute = async () => { + const cfg = await Config.getExecution() const defaults = PermissionNext.fromConfig({ "*": "allow", @@ -424,14 +425,21 @@ export namespace Agent { } return result - }) + } + + const state = Instance.state(compute) + + /** Rebuild project-defined specialists and permissions after trust changes. */ + export function invalidate() { + State.clear(Instance.directory, compute) + } export async function get(agent: string) { return state().then((x) => x[agent]) } export async function list() { - const cfg = await Config.get() + const cfg = await Config.getExecution() return pipe( await state(), values(), @@ -440,7 +448,7 @@ export namespace Agent { } export async function defaultAgent() { - const cfg = await Config.get() + const cfg = await Config.getExecution() const agents = await state() if (cfg.default_agent) { @@ -459,7 +467,7 @@ export namespace Agent { } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { - const cfg = await Config.get() + const cfg = await Config.getExecution() const defaultModel = input.model ?? (await Provider.defaultModel()) const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) const language = await Provider.getLanguage(model) diff --git a/backend/cli/src/artifact/store.ts b/backend/cli/src/artifact/store.ts index 117e0c17..f00bc972 100644 --- a/backend/cli/src/artifact/store.ts +++ b/backend/cli/src/artifact/store.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises" import path from "node:path" import z from "zod" import { Global } from "@/global" +import { FileLease } from "@/util/file-lease" import { Lock } from "@/util/lock" export namespace ArtifactStore { @@ -231,9 +232,9 @@ export namespace ArtifactStore { async function prepare() { await Promise.all([fs.mkdir(blobs, { recursive: true }), fs.mkdir(partials, { recursive: true })]) const db = new Database(database, { create: true }) + db.exec("PRAGMA busy_timeout = 5000") db.exec("PRAGMA journal_mode = WAL") db.exec("PRAGMA synchronous = FULL") - db.exec("PRAGMA busy_timeout = 5000") db.exec(schema) const columns = db.query("PRAGMA table_info(artifacts)").all() as Array<{ name: string }> if (!columns.some((column) => column.name === "trashed_at")) { @@ -357,6 +358,18 @@ export namespace ArtifactStore { return path.join(blobs, sha256.slice(0, 2), sha256.slice(2, 4), sha256) } + async function digest(content: BunFile) { + const hasher = new Bun.CryptoHasher("sha256") + const reader = content.stream().getReader() + const read = async (): Promise => { + const item = await reader.read() + if (item.done) return hasher.digest("hex") + hasher.update(item.value) + return read() + } + return read() + } + function rows(db: Database, projectID: string, artifactID?: string, state: "active" | "trash" = "active") { const suffix = artifactID ? " WHERE a.project_id = ?1 AND a.id = ?2" @@ -369,6 +382,10 @@ export namespace ArtifactStore { export async function save(input: SaveInput): Promise { const staged = await stage(input.content) using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock).catch(async (error) => { + await fs.rm(staged.file, { force: true }) + throw error + }) const db = await prepare() const target = blob(staged.sha256) const now = Date.now() @@ -376,32 +393,64 @@ export namespace ArtifactStore { const versionID = `ver_${crypto.randomUUID()}` const executionID = input.execution ? `exe_${crypto.randomUUID()}` : undefined const source = input.sourcePath.replaceAll("\\", "/") - const existing = db - .query("SELECT id FROM artifacts WHERE project_id = ?1 AND source_key = ?2") - .get(input.projectID, source) as { id: string } | null - const id = existing?.id ?? artifactID - const count = db - .query("SELECT coalesce(max(version), 0) AS value FROM versions WHERE artifact_id = ?1") - .get(id) as { - value: number - } - const number = count.value + 1 const relative = path.relative(root, target) - const created = !(await Bun.file(target).exists()) - if (created) { - await fs.mkdir(path.dirname(target), { recursive: true }) - await fs.rename(staged.file, target) + await fs.mkdir(path.dirname(target), { recursive: true }) + const published = await fs.link(staged.file, target).then( + () => true, + async (error: NodeJS.ErrnoException) => { + if (error.code === "EEXIST") return false + await fs.rm(staged.file, { force: true }) + throw error + }, + ) + if (!published) { + const prior = await fs.lstat(target).catch(() => undefined) + const valid = + !!prior?.isFile() && + !prior.isSymbolicLink() && + prior.size === staged.size && + (await digest(Bun.file(target))) === staged.sha256 + if (valid) await fs.rm(staged.file, { force: true }) + if (!valid) await fs.rename(staged.file, target) + } else { + await fs.rm(staged.file, { force: true }) + } + const stored = await fs.lstat(target) + if ( + !stored.isFile() || + stored.isSymbolicLink() || + stored.size !== staged.size || + (await digest(Bun.file(target))) !== staged.sha256 + ) { + db.close() + throw new Error(`Artifact blob ${staged.sha256} failed its size integrity check`) } - if (!created) await fs.rm(staged.file, { force: true }) try { db.exec("BEGIN IMMEDIATE") + const existing = db + .query("SELECT id FROM artifacts WHERE project_id = ?1 AND source_key = ?2") + .get(input.projectID, source) as { id: string } | null + const id = existing?.id ?? artifactID + const count = db + .query("SELECT coalesce(max(version), 0) AS value FROM versions WHERE artifact_id = ?1") + .get(id) as { + value: number + } + const number = count.value + 1 db.query("INSERT OR IGNORE INTO blobs (sha256, size, path, created_at) VALUES (?1, ?2, ?3, ?4)").run( staged.sha256, staged.size, relative, now, ) + const record = db.query("SELECT size, path FROM blobs WHERE sha256 = ?1").get(staged.sha256) as { + size: number + path: string + } + if (record.size !== staged.size || record.path !== relative) { + throw new Error(`Artifact blob ${staged.sha256} conflicts with the stored integrity record`) + } if (!existing) { db.query( `INSERT INTO artifacts @@ -461,17 +510,16 @@ export namespace ArtifactStore { WHERE id = ?5`, ).run(input.title ?? input.filename, input.kind, versionID, now, id) db.exec("COMMIT") + + const row = rows(db, input.projectID, id)[0] + db.close() + if (!row) throw new Error(`Artifact ${id} was not saved`) + return artifact(row) } catch (error) { db.exec("ROLLBACK") db.close() - if (created) await fs.rm(target, { force: true }) throw error } - - const row = rows(db, input.projectID, id)[0] - db.close() - if (!row) throw new Error(`Artifact ${id} was not saved`) - return artifact(row) } export async function list(projectID: string, state: "active" | "trash" = "active"): Promise { @@ -484,6 +532,7 @@ export namespace ArtifactStore { export async function rename(projectID: string, artifactID: string, title: string): Promise { using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock) const db = await prepare() db.query("UPDATE artifacts SET title = ?1, updated_at = ?2 WHERE project_id = ?3 AND id = ?4").run( title, @@ -498,6 +547,7 @@ export namespace ArtifactStore { export async function trash(projectID: string, artifactID: string, now = Date.now()): Promise { using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock) const db = await prepare() db.query( "UPDATE artifacts SET state = 'trash', trashed_at = ?1, updated_at = ?1 WHERE project_id = ?2 AND id = ?3", @@ -509,6 +559,7 @@ export namespace ArtifactStore { export async function restore(projectID: string, artifactID: string): Promise { using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock) const db = await prepare() db.query( "UPDATE artifacts SET state = 'active', trashed_at = NULL, updated_at = ?1 WHERE project_id = ?2 AND id = ?3", @@ -520,18 +571,19 @@ export namespace ArtifactStore { export async function sweep(now = Date.now()) { using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock) const db = await prepare() const cutoff = now - TRASH_RETENTION_MS - const stale = db.query("SELECT id FROM artifacts WHERE state = 'trash' AND trashed_at <= ?1").all(cutoff) as Array<{ - id: string - }> - if (!stale.length) { - db.close() - return 0 - } const orphaned = (() => { try { db.exec("BEGIN IMMEDIATE") + const stale = db + .query("SELECT id FROM artifacts WHERE state = 'trash' AND trashed_at <= ?1") + .all(cutoff) as Array<{ id: string }> + if (!stale.length) { + db.exec("COMMIT") + return { stale: 0, unused: [] as Array<{ path: string }> } + } const remove = db.query("DELETE FROM artifacts WHERE id = ?1") stale.forEach((item) => remove.run(item.id)) const unused = db @@ -543,7 +595,7 @@ export namespace ArtifactStore { "DELETE FROM blobs WHERE NOT EXISTS (SELECT 1 FROM versions WHERE versions.sha256 = blobs.sha256)", ).run() db.exec("COMMIT") - return unused + return { stale: stale.length, unused } } catch (error) { db.exec("ROLLBACK") throw error @@ -551,8 +603,8 @@ export namespace ArtifactStore { db.close() } })() - await Promise.all(orphaned.map((item) => fs.rm(path.join(root, item.path), { force: true }))) - return stale.length + await Promise.all(orphaned.unused.map((item) => fs.rm(path.join(root, item.path), { force: true }))) + return orphaned.stale } export async function get(projectID: string, artifactID: string): Promise { @@ -598,13 +650,17 @@ export namespace ArtifactStore { const stored = db.query("SELECT path FROM blobs WHERE sha256 = ?1").get(row.sha256) as { path: string } | null db.close() if (!stored) return - const content = Bun.file(path.join(root, stored.path)) - if (!(await content.exists()) || content.size !== row.size) return + const filepath = path.join(root, stored.path) + const stat = await fs.lstat(filepath).catch(() => undefined) + if (!stat?.isFile() || stat.isSymbolicLink() || stat.size !== row.size) return + const content = Bun.file(filepath) + if ((await digest(content)) !== row.sha256) return return { info: version(row), content } } export async function reset() { using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(lock) await fs.rm(root, { recursive: true, force: true }) } } diff --git a/backend/cli/src/auth/index.ts b/backend/cli/src/auth/index.ts index 574572b7..4db24903 100644 --- a/backend/cli/src/auth/index.ts +++ b/backend/cli/src/auth/index.ts @@ -4,6 +4,7 @@ import { JsonStore } from "../util/jsonstore" import z from "zod" import { Config } from "../config/config" import { Log } from "../util/log" +import { CredentialLifecycle } from "../credentials/lifecycle" export const OAUTH_DUMMY_KEY = "synsc-oauth-dummy-key" @@ -71,7 +72,9 @@ export namespace Auth { } export async function set(key: string, info: Info) { - await JsonStore.update(filepath, (data) => ({ ...data, [key]: info })) + await CredentialLifecycle.mutate(`provider-auth.set:${key}`, () => + JsonStore.update(filepath, (data) => ({ ...data, [key]: info })), + ) // Adding a real (non-Atlas) OpenRouter key while Managed spend is on // means the user is bringing their own key - flip the toggle to Own @@ -113,8 +116,10 @@ export namespace Auth { } export async function remove(key: string) { - await JsonStore.update(filepath, (data) => { - delete data[key] - }) + await CredentialLifecycle.mutate(`provider-auth.remove:${key}`, () => + JsonStore.update(filepath, (data) => { + delete data[key] + }), + ) } } diff --git a/backend/cli/src/auth/wellknown-command.ts b/backend/cli/src/auth/wellknown-command.ts new file mode 100644 index 00000000..043afb65 --- /dev/null +++ b/backend/cli/src/auth/wellknown-command.ts @@ -0,0 +1,282 @@ +import os from "node:os" +import path from "node:path" +import { spawn, type ChildProcess } from "node:child_process" +import { Config } from "../config/config" +import { CredentialLifecycle } from "../credentials/lifecycle" +import { CredentialProcessLedger } from "../credentials/process-ledger" +import { OpenScience } from "../openscience" +import { ProcessIdentity } from "../process/process-identity" +import { WindowsJobLauncher } from "../process/windows-job-launcher" +import { Instance } from "../project/instance" +import { Sandbox } from "../sandbox/sandbox" +import { Shell } from "../shell/shell" + +/** + * Executes a command returned by an unsigned well-known document only after + * the CLI has obtained an explicit, local approval for its exact argv. + * + * This runner deliberately has no approval UI of its own. Keeping consent in + * the CLI and execution here makes it impossible for a network response to + * accidentally become ambient local execution through another call site. + */ +export namespace WellKnownAuthCommand { + export const DEFAULT_TIMEOUT_MS = 15_000 + export const MAX_STDOUT_BYTES = 64 * 1024 + export const MAX_STDERR_BYTES = 32 * 1024 + + const POSIX_ENV = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "TMPDIR", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AZURE_CONFIG_DIR", + "CLOUDSDK_CONFIG", + "CLOUDSDK_ACTIVE_CONFIG_NAME", + "GH_HOST", + "KUBECONFIG", + ]) + const WINDOWS_ENV = new Set([ + ...POSIX_ENV, + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + ]) + + export interface RunOptions { + argv: string[] + timeoutMs?: number + maxStdoutBytes?: number + maxStderrBytes?: number + } + + export function environment(source: NodeJS.ProcessEnv = process.env): Record { + const allowed = process.platform === "win32" ? WINDOWS_ENV : POSIX_ENV + const result: Record = {} + for (const [key, value] of Object.entries(source)) { + if (!value) continue + const normalized = process.platform === "win32" ? key.toUpperCase() : key + if (normalized.startsWith("LC_") || allowed.has(normalized)) result[key] = value + } + return { + ...result, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } + } + + function credentialRoots(env: Record): string[] { + const home = env.HOME || env.USERPROFILE + const roots = new Set() + const add = (value?: string) => { + if (!value) return + roots.add(path.resolve(value)) + } + if (home) { + add(path.join(home, ".aws")) + add(path.join(home, ".azure")) + add(path.join(home, ".config", "gcloud")) + add(path.join(home, ".config", "gh")) + add(path.join(home, ".kube")) + } + for (const key of [ + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AZURE_CONFIG_DIR", + "CLOUDSDK_CONFIG", + "KUBECONFIG", + ]) { + add(env[key]) + } + return [...roots] + } + + function outsideRoots(value: string, roots: string[]): boolean { + const exact = path.resolve(value) + return !roots.some((root) => exact === root || exact.startsWith(root + path.sep)) + } + + function collect(stream: NodeJS.ReadableStream, limit: number, label: string): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let settled = false + const fail = (error: unknown) => { + if (settled) return + settled = true + reject(error) + } + stream.on("data", (value: Buffer | string) => { + if (settled) return + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + size += chunk.length + if (size > limit) return fail(new Error(`Well-known auth ${label} exceeded ${limit} bytes`)) + chunks.push(chunk) + }) + stream.once("error", fail) + stream.once("end", () => { + if (settled) return + settled = true + resolve(Buffer.concat(chunks, size).toString("utf8")) + }) + }) + } + + async function stop(child: ChildProcess): Promise { + await Shell.killTree(child, { + detached: process.platform !== "win32", + exited: () => child.exitCode !== null || child.signalCode !== null, + }) + } + + export async function run(input: RunOptions): Promise { + if (!input.argv.length || input.argv.some((value) => !value || value.includes("\0"))) { + throw new Error("Well-known auth command contains an invalid argv") + } + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS + const maxStdout = input.maxStdoutBytes ?? MAX_STDOUT_BYTES + const maxStderr = input.maxStderrBytes ?? MAX_STDERR_BYTES + for (const [label, value] of [ + ["timeout", timeoutMs], + ["stdout limit", maxStdout], + ["stderr limit", maxStderr], + ] as const) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Well-known auth ${label} must be positive`) + } + + // Hold the shared credential mutation lease for the whole short-lived + // helper. A second server cannot rotate the credential snapshot midway + // through token acquisition, and this CLI mutates auth.json only afterward. + return CredentialLifecycle.admit(async () => { + const env = environment() + const readable = credentialRoots(env) + const policy = await Config.trustedSandbox() + const sandbox = Sandbox.wrapArgv({ + file: input.argv[0]!, + args: input.argv.slice(1), + workspace: [], + readable, + unreadable: OpenScience.kernelSensitivePaths().filter((value) => outsideRoots(value, readable)), + options: policy, + }) + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + Sandbox.cleanup(sandbox) + throw new Error("Could not capture the Linux server identity for well-known auth launch") + } + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args, linuxOwner }) + let child: ChildProcess + try { + child = spawn(wrapped.file, wrapped.args, { + cwd: os.tmpdir(), + env, + shell: false, + detached: process.platform !== "win32", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + + const id = `wellknown-auth-${crypto.randomUUID()}` + const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + let registered = false + let normal = false + let timer: ReturnType | undefined + let bodyFailure: unknown + try { + registered = await CredentialProcessLedger.register({ + id, + kind: "provider", + pid: child.pid!, + detached: process.platform !== "win32", + projectID: Instance.project.id, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error("Well-known auth command exited before durable process registration") + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid!) + } + + const output = Promise.all([ + collect(child.stdout!, maxStdout, "stdout"), + collect(child.stderr!, maxStderr, "stderr"), + ]) + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Well-known auth command timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + }) + const [[stdout, stderr], settled] = await Promise.race([Promise.all([output, completion]), timeout]) + normal = true + if (settled.code !== 0) { + const status = settled.code === null ? `signal ${settled.signal ?? "unknown"}` : `exit ${settled.code}` + throw new Error( + `Well-known auth command ${status}: ${OpenScience.redactSecrets(stderr.trim()) || "no stderr"}`, + ) + } + const token = stdout.trim() + if (!token) throw new Error("Well-known auth command produced no token") + return token + } catch (error) { + bodyFailure = error + if (!normal) { + const failures: unknown[] = [] + if (registered) { + await CredentialProcessLedger.revoke({ id, kind: "provider" }).catch((failure) => failures.push(failure)) + } + await stop(child).catch((failure) => failures.push(failure)) + if (failures.length) throw new AggregateError([error, ...failures], "Well-known auth cleanup failed") + } + throw error + } finally { + if (timer) clearTimeout(timer) + if (normal && registered) { + try { + const complete = await CredentialProcessLedger.complete(id) + if (!complete) await CredentialProcessLedger.revoke({ id, kind: "provider" }) + } catch (cleanupFailure) { + if (bodyFailure) { + throw new AggregateError([bodyFailure, cleanupFailure], "Well-known auth completion cleanup failed") + } + throw cleanupFailure + } + } + Sandbox.cleanup(sandbox) + } + }) + } +} diff --git a/backend/cli/src/cli/cmd/auth.ts b/backend/cli/src/cli/cmd/auth.ts index eb112282..483e4e2b 100644 --- a/backend/cli/src/cli/cmd/auth.ts +++ b/backend/cli/src/cli/cmd/auth.ts @@ -15,11 +15,150 @@ import { OpenScience } from "../../openscience" import { Log } from "../../util/log" import { runLocalModelSetup } from "./local" import type { Hooks } from "@synsci/plugin" +import z from "zod" +import { WellKnownAuthCommand } from "../../auth/wellknown-command" const log = Log.create({ service: "cmd.logout" }) type PluginAuth = NonNullable +const WellKnownAuth = z + .object({ + auth: z + .object({ + command: z + .array( + z + .string() + .min(1) + .max(4096) + .refine((value) => !value.includes("\0"), "argv cannot contain NUL"), + ) + .min(1) + .max(32), + env: z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid environment variable name"), + }) + .strict(), + }) + .passthrough() + +export type WellKnownAuth = z.infer + +export class WellKnownAuthApprovalRequired extends Error { + constructor() { + super("A command from an unsigned well-known endpoint requires interactive approval") + this.name = "WellKnownAuthApprovalRequired" + } +} + +export class WellKnownAuthDeclined extends Error { + constructor() { + super("The well-known auth command was not approved") + this.name = "WellKnownAuthDeclined" + } +} + +const WELLKNOWN_MAX_BYTES = 64 * 1024 +const WELLKNOWN_FETCH_TIMEOUT_MS = 10_000 + +async function boundedResponse(response: Response, maxBytes = WELLKNOWN_MAX_BYTES): Promise { + const declared = Number(response.headers.get("content-length")) + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error(`Well-known auth document exceeds ${maxBytes} bytes`) + } + if (!response.body) return "" + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > maxBytes) { + await reader.cancel().catch(() => undefined) + throw new Error(`Well-known auth document exceeds ${maxBytes} bytes`) + } + chunks.push(next.value) + } + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +/** Fetch and validate only data. This function never executes anything from + * the response; the separate approval boundary below is mandatory. */ +export async function fetchWellKnownAuth( + endpoint: string, + options: { fetcher?: typeof fetch; timeoutMs?: number; maxBytes?: number } = {}, +): Promise { + const base = new URL(endpoint) + if (base.protocol !== "http:" && base.protocol !== "https:") throw new Error("Endpoint must use HTTP or HTTPS") + if (base.username || base.password) throw new Error("Endpoint URLs must not contain credentials") + if (base.search || base.hash) throw new Error("Endpoint URLs must not contain a query or fragment") + const url = `${base.toString().replace(/\/+$/, "")}/.well-known/openscience` + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? WELLKNOWN_FETCH_TIMEOUT_MS) + try { + const response = await (options.fetcher ?? fetch)(url, { + signal: controller.signal, + redirect: "error", + headers: { accept: "application/json" }, + }) + if (!response.ok) throw new Error(`Well-known auth endpoint returned HTTP ${response.status}`) + const text = await boundedResponse(response, options.maxBytes) + let value: unknown + try { + value = JSON.parse(text) + } catch { + throw new Error("Well-known auth endpoint returned invalid JSON") + } + return WellKnownAuth.parse(value) + } finally { + clearTimeout(timer) + } +} + +/** Require a fresh local decision for the exact argv. Non-interactive callers + * fail closed: piping input or running in CI is never treated as consent. */ +export async function approveWellKnownAuthCommand( + command: string[], + options: { + interactive?: boolean + confirm?: (message: string) => Promise + } = {}, +): Promise { + if (!(options.interactive ?? !!process.stdin.isTTY)) throw new WellKnownAuthApprovalRequired() + const message = `Run this command from the unsigned endpoint?\n${JSON.stringify(command)}` + const approved = await (options.confirm + ? options.confirm(message) + : prompts.confirm({ message, initialValue: false })) + if (prompts.isCancel(approved) || approved !== true) throw new WellKnownAuthDeclined() +} + +/** The only composition that turns a well-known auth document into a local + * command. Tests inject the runner to prove refusal happens before execution. */ +export async function runApprovedWellKnownAuth( + wellknown: WellKnownAuth, + options: { + interactive?: boolean + confirm?: (message: string) => Promise + onApproved?: () => void | Promise + run?: (input: WellKnownAuthCommand.RunOptions) => Promise + } = {}, +): Promise { + await approveWellKnownAuthCommand(wellknown.auth.command, options) + await options.onApproved?.() + return (options.run ?? WellKnownAuthCommand.run)({ argv: wellknown.auth.command }) +} + /** * Handle plugin-based authentication flow. * Returns true if auth was handled, false if it should fall through to default handling. @@ -303,23 +442,29 @@ export const AuthLoginCommand = cmd({ } if (endpointUrl) { - const wellknown = await fetch(`${endpointUrl}/.well-known/openscience`).then((x) => x.json() as any) - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Bun.spawn({ - cmd: wellknown.auth.command, - stdout: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - prompts.log.error("Failed") + const wellknown = await fetchWellKnownAuth(endpointUrl) + let token: string + try { + token = await runApprovedWellKnownAuth(wellknown, { + onApproved: () => prompts.log.info(`Running approved command ${JSON.stringify(wellknown.auth.command)}`), + }) + } catch (error) { + if (error instanceof WellKnownAuthApprovalRequired) { + prompts.log.error( + "The endpoint requested a local command, but this shell cannot show an approval prompt.", + ) + } else if (error instanceof WellKnownAuthDeclined) { + prompts.log.info("Command not run") + } else { + throw error + } prompts.outro("Done") return } - const token = await new Response(proc.stdout).text() await Auth.set(endpointUrl, { type: "wellknown", key: wellknown.auth.env, - token: token.trim(), + token, }) prompts.log.success("Logged into " + endpointUrl) prompts.outro("Done") diff --git a/backend/cli/src/cli/cmd/sandbox.ts b/backend/cli/src/cli/cmd/sandbox.ts index 3e345e4d..9ae09cff 100644 --- a/backend/cli/src/cli/cmd/sandbox.ts +++ b/backend/cli/src/cli/cmd/sandbox.ts @@ -87,7 +87,13 @@ const EnableCommand = cmd({ if (args.network) patch.network = args.network as "allow" | "deny" if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow" const allow = args.allow as string[] | undefined - if (allow?.length) patch.allowWrite = allow + if (allow?.length) { + patch.allowWrite = allow.map((value) => { + const canonical = Sandbox.writableGrant(value) + if (!canonical) throw new Error(`Writable sandbox path is invalid or over-broad: ${value}`) + return canonical + }) + } await Config.setSandbox(patch) UI.empty() UI.println(`${S.TEXT_SUCCESS_BOLD}Sandbox enabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`) diff --git a/backend/cli/src/command/index.ts b/backend/cli/src/command/index.ts index a12beae7..b3d5ebcd 100644 --- a/backend/cli/src/command/index.ts +++ b/backend/cli/src/command/index.ts @@ -7,6 +7,7 @@ import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import PROMPT_LEARN from "./template/learn.txt" import { MCP } from "../mcp" +import { State } from "../project/state" export namespace Command { export const Event = { @@ -63,8 +64,11 @@ export namespace Command { HANDOFF: "handoff", } as const - const state = Instance.state(async () => { - const cfg = await Config.get() + const compute = async () => { + // Command templates may contain executable shell interpolation (`!` + + // backticks). Project-owned command definitions therefore belong to the + // same trust boundary as every other executable project setting. + const cfg = await Config.getExecution() const result: Record = { [Default.INIT]: { @@ -155,7 +159,15 @@ export namespace Command { } return result - }) + } + + const state = Instance.state(compute) + + /** Drop project-derived command and MCP-prompt definitions after an + * authority transition. The next read rebuilds against current trust. */ + export function invalidate() { + State.clear(Instance.directory, compute) + } export async function get(name: string) { return state().then((x) => x[name]) diff --git a/backend/cli/src/compute/jobs.ts b/backend/cli/src/compute/jobs.ts index 8c195747..086406a0 100644 --- a/backend/cli/src/compute/jobs.ts +++ b/backend/cli/src/compute/jobs.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto" import { createReadStream } from "node:fs" import fs from "node:fs/promises" import path from "node:path" +import os from "node:os" import z from "zod" import { Global } from "../global" import { OpenScience } from "../openscience" @@ -10,11 +11,21 @@ import { Shell } from "../shell/shell" import { Instance } from "../project/instance" import { Sandbox } from "../sandbox/sandbox" import { Filesystem } from "../util/filesystem" +import { FileLease } from "../util/file-lease" import { ProvenanceEnvelope } from "../science/provenance/envelope" import { ExecutionAuthority } from "../project/execution" import { ComputeLifecycle } from "./lifecycle" import { ModalAdapter } from "./modal/adapter" import { ModalPlan } from "./modal/plan" +import { ArtifactStore } from "../artifact/store" +import { CredentialProcessLedger } from "../credentials/process-ledger" +import { AuthoritySignal } from "../project/authority-signal" +import { SshAdapter } from "./ssh/adapter" +import { SshPlan } from "./ssh/plan" +import { WindowsJobLauncher } from "../process/windows-job-launcher" +import { DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX } from "../process/darwin-responsibility-launcher" +import { DataRootBarrier } from "../global/data-root-barrier" +import { SecretFile } from "../util/secret-file" export class ComputeJobsCorruptError extends Error { constructor( @@ -38,12 +49,35 @@ export namespace ComputeJobs { export const Host = z.object({ id: z.string(), - label: z.string(), - host: z.string(), - user: z.string().optional(), - port: z.number().int().positive().optional(), + label: z.string().trim().min(1).max(120), + host: z + .string() + .trim() + .min(1) + .max(253) + .regex(/^\S+$/, "SSH hosts cannot contain whitespace") + .refine((value) => !value.startsWith("-"), "SSH hosts cannot begin with a hyphen"), + user: z + .string() + .trim() + .min(1) + .max(120) + .regex(/^\S+$/, "SSH users cannot contain whitespace") + .refine((value) => !value.includes("@"), "SSH users cannot contain @") + .refine((value) => !value.startsWith("-"), "SSH users cannot begin with a hyphen") + .optional(), + port: z.number().int().min(1).max(65_535).optional(), scheduler: Scheduler.default("none"), workdir: z.string().optional(), + fingerprint: z.string().startsWith("SHA256:").optional(), + host_key: z + .string() + .trim() + .min(1) + .max(16_000) + .refine((value) => !value.includes("\n"), "SSH host keys must contain one line") + .optional(), + concurrency: z.number().int().min(1).max(100).default(4), }) export type Host = z.infer @@ -56,6 +90,8 @@ export namespace ComputeJobs { gpu: z.boolean(), slurm: z.boolean(), pbs: z.boolean(), + fingerprint: z.string().startsWith("SHA256:").optional(), + host_key: z.string().optional(), error: z.string().optional(), }) export type Probe = z.infer @@ -86,6 +122,9 @@ export namespace ComputeJobs { size: z.number().int().nonnegative(), sha256: z.string().regex(/^[a-f0-9]{64}$/), modified_at: z.string(), + artifact_id: z.string().optional(), + version_id: z.string().optional(), + version: z.number().int().positive().optional(), }) export type Artifact = z.infer @@ -154,6 +193,7 @@ export namespace ComputeJobs { completed_at: z.string().optional(), exit_code: z.number().int().nullable().optional(), pid: z.number().int().positive().optional(), + process_identity: z.string().length(64).optional(), error: z.string().optional(), resources: Resources.optional(), modules: z.array(z.string()).optional(), @@ -203,9 +243,24 @@ export namespace ComputeJobs { volume: z.string().optional(), }) .optional(), + ssh: z + .object({ + protocol: z.literal(1), + host: Host, + root: z.string(), + cwd: z.string(), + fingerprint: z.string().startsWith("SHA256:"), + uploads: SshPlan.Upload.array(), + upload_bytes: z.number().int().nonnegative(), + approval: z.string().length(64), + }) + .optional(), }) export type Job = z.infer + export const Plan = z.union([ModalPlan.Schema, SshPlan.Schema]) + export type Plan = z.infer + export type ModalProvider = Pick export type Options = { @@ -235,6 +290,8 @@ export namespace ComputeJobs { host?: Host modal?: ModalAdapter.Context provider?: ModalProvider + dataRoot: DataRootBarrier.Operation + dataRootOwner?: DataRootBarrier.Owner } type Scope = { @@ -246,16 +303,64 @@ export namespace ComputeJobs { type Launch = { argv: string[] sandbox?: Job["sandbox"] + temporary?: string } const active = new Map() - const slots = new Map() const claims = new Set() const locks = new Map>() const terminal = new Set(["succeeded", "failed", "cancelled", "interrupted"]) const recoveryLimit = 3 const recoveryDelay = 15_000 + async function activate(key: string, runtime: Omit): Promise { + const current = active.get(key) + if (current) { + const changedOwner = + !!runtime.dataRootOwner && + (runtime.dataRootOwner.pid !== current.dataRootOwner?.pid || + runtime.dataRootOwner.identity !== current.dataRootOwner?.identity) + if (changedOwner) await current.dataRoot.reassign(runtime.dataRootOwner!) + active.set(key, { ...runtime, dataRoot: current.dataRoot }) + return + } + const dataRoot = await DataRootBarrier.enter(logsOf(runtime.root), 120_000, runtime.dataRootOwner) + const collision = active.get(key) + if (collision) { + await dataRoot[Symbol.asyncDispose]() + const changedOwner = + !!runtime.dataRootOwner && + (runtime.dataRootOwner.pid !== collision.dataRootOwner?.pid || + runtime.dataRootOwner.identity !== collision.dataRootOwner?.identity) + if (changedOwner) await collision.dataRoot.reassign(runtime.dataRootOwner!) + active.set(key, { ...runtime, dataRoot: collision.dataRoot }) + return + } + active.set(key, { ...runtime, dataRoot }) + } + + async function deactivate(key: string): Promise { + const runtime = active.get(key) + if (!runtime || !active.delete(key)) return + await runtime.dataRoot[Symbol.asyncDispose]() + } + + async function currentAuthority(authority: ExecutionAuthority.Decision) { + const current = await Instance.provide({ + directory: authority.workspace, + fn: () => + ExecutionAuthority.require({ + projectID: authority.projectID, + sessionID: authority.sessionID, + capability: authority.capability, + }), + }) + if (current.generation !== authority.generation) { + throw new Error("Execution authority changed while compute was being prepared; retry the job") + } + return current + } + function move(job: Job, event: ComputeLifecycle.Event, value: Partial = {}): Job { const lifecycle = ComputeLifecycle.transition(job.lifecycle ?? ComputeLifecycle.from(job.status), event) return Job.parse({ ...job, ...value, status: ComputeLifecycle.legacy(lifecycle), lifecycle }) @@ -269,10 +374,48 @@ export namespace ComputeJobs { const rootOf = (workspace: string, options: Options) => options.root ?? path.join(options.data ?? Global.Path.data, "compute", "projects", scopeKey(workspace)) const metaOf = (root: string) => path.join(root, "jobs.json") + const modalAdmissionOf = (root: string) => path.join(root, "modal-admission.lock") + const modalLeaseOf = (root: string, id: string) => path.join(root, "modal-leases", `${id}.lock`) + const modalOperationOf = (root: string, id: string) => path.join(root, "modal-operations", `${id}.lock`) + const localLeaseOf = (root: string, id: string) => path.join(root, "local-leases", `${id}.lock`) + const sshAdmissionOf = (root: string, host: string) => + path.join(root, "ssh-admission", `${crypto.createHash("sha256").update(host).digest("hex")}.lock`) + const sshLeaseOf = (root: string, id: string) => path.join(root, "ssh-leases", `${id}.lock`) + const sshOperationOf = (root: string, id: string) => path.join(root, "ssh-operations", `${id}.lock`) + + function reservesModal(job: Job) { + if (job.target.kind !== "modal") return false + const lifecycle = job.lifecycle ?? ComputeLifecycle.from(job.status) + return !terminal.has(job.status) || lifecycle.resource !== "closed" + } + + function reservesSsh(job: Job, host: string) { + if (job.target.kind !== "ssh" || job.target.host_id !== host) return false + const lifecycle = job.lifecycle ?? ComputeLifecycle.from(job.status) + return !terminal.has(job.status) || lifecycle.recoverable || lifecycle.resource !== "closed" + } + + async function releaseLease(lease: AsyncDisposable) { + await lease[Symbol.asyncDispose]() + } + + function leaseBusy(error: unknown) { + return error instanceof Error && error.message.startsWith("Timed out waiting for another OpenScience process") + } const logsOf = (root: string) => path.join(root, "jobs") const eventsOf = (root: string, id: string) => path.join(logsOf(root), `${id}.events.log`) const exitOf = (root: string, id: string) => path.join(logsOf(root), `${id}.exit`) const keyOf = (root: string, id: string) => `${root}\0${id}` + const credentialProcessID = (root: string, id: string) => + `compute-${crypto.createHash("sha256").update(`${root}\0${id}`).digest("hex")}` + + async function completeCredentialProcess(id: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (await CredentialProcessLedger.complete(id)) return + await Bun.sleep(20) + } + throw new Error(`Credential-bearing compute process ${id} did not become safely reapable`) + } async function scoped(options: Options): Promise { const requested = options.workspace ?? Instance.directory @@ -306,6 +449,7 @@ export namespace ComputeJobs { async function write(root: string, jobs: Job[]): Promise { const clean = await OpenScience.scrubSecrets(jobs) const filepath = metaOf(root) + await using operation = await DataRootBarrier.enter(filepath) const temp = `${filepath}.${process.pid}.${crypto.randomUUID()}.tmp` await fs.mkdir(root, { recursive: true }) await (async () => { @@ -326,6 +470,7 @@ export namespace ComputeJobs { } async function event(root: string, id: string, value: string) { + await using operation = await DataRootBarrier.enter(eventsOf(root, id)) await fs.mkdir(logsOf(root), { recursive: true }) const message = OpenScience.redactSecrets(value).replace(/\s+$/, "") await fs.appendFile(eventsOf(root, id), `[${new Date().toISOString()}] ${message}\n`, { mode: 0o600 }) @@ -351,6 +496,7 @@ export namespace ComputeJobs { } async function snapshot(filepath: string, value: string) { + await using operation = await DataRootBarrier.enter(filepath) const temp = `${filepath}.${process.pid}.${crypto.randomUUID()}.tmp` await fs.mkdir(path.dirname(filepath), { recursive: true }) await fs @@ -379,6 +525,7 @@ export namespace ComputeJobs { const task = prior .catch(() => undefined) .then(async () => { + await using lease = await FileLease.acquire(`${metaOf(root)}.lock`) const jobs = await read(root).catch((error) => preserve(root, error)) const result = await edit(jobs) await write(root, jobs) @@ -394,12 +541,226 @@ export namespace ComputeJobs { return task } - function alive(pid: number): boolean { + async function processIdentity(pid: number): Promise { + return CredentialProcessLedger.identity(pid) + } + + async function owns(pid: number, identity: string | undefined) { + return CredentialProcessLedger.owns(pid, identity) + } + + async function localExit(root: string, id: string) { + const marker = await Bun.file(exitOf(root, id)) + .text() + .catch(() => undefined) + return marker?.trim().match(/^-?\d+$/) ? Number(marker.trim()) : undefined + } + + async function recoverLocal(job: Job, scope: Scope): Promise { + if (!job.pid) return + for (;;) { + const exit = await localExit(scope.root, job.id) + if (exit !== undefined) { + const captured = await capture(job) + .then((value) => ({ ...value, capture_error: undefined })) + .catch((error) => ({ capture_error: error instanceof Error ? error.message : String(error) })) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const finished = move( + jobs[index]!, + { type: "finish", outcome: exit === 0 ? "succeeded" : "failed" }, + { + completed_at: new Date().toISOString(), + exit_code: exit, + pid: undefined, + process_identity: undefined, + ...captured, + }, + ) + const closed = move(finished, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + if (await owns(job.pid, job.process_identity)) { + await Bun.sleep(50) + continue + } + const reported = await localExit(scope.root, job.id) + if (reported !== undefined) continue + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const interrupted = move( + jobs[index]!, + { type: "interrupt" }, + { + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = move(interrupted, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + } + + async function sshRun( + scope: Scope, + job: Job, + host: Host, + authority: ExecutionAuthority.Decision, + script: string, + options: { stdin?: string; stdout?: string; timeout?: number; authorize?: boolean } = {}, + ) { + if (options.authorize !== false) await currentAuthority(authority) + const known = await SshAdapter.known(host, scope.root) + const spec = SshAdapter.argv(host, known, script) + const input = options.stdin ? await fs.open(options.stdin, "r") : undefined + const output = options.stdout ? await fs.open(options.stdout, "w", 0o600) : undefined + const errors: Buffer[] = [] + const chunks: Buffer[] = [] + const detached = process.platform !== "win32" + const ledger = `${credentialProcessID(scope.root, job.id)}-${crypto.randomUUID()}` + const cleanupGate = async (release?: string) => { + if (!release) return + await Promise.all([ + fs.rm(release, { force: true }).catch(() => undefined), + fs.rm(`${release}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, { force: true }).catch(() => undefined), + ]) + } try { - process.kill(pid, 0) - return true - } catch { - return false + return await AuthoritySignal.exclusive(() => + OpenScience.withSubprocessEnv(process.env, async (env) => { + if (options.authorize !== false) await currentAuthority(authority) + const transport = Object.fromEntries( + [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TMPDIR", + "SSH_AUTH_SOCK", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "USERPROFILE", + ].flatMap((key) => (env[key] ? [[key, env[key]]] : [])), + ) + // This is OpenScience's fixed, host-key-pinned broker transport, not + // project-authored code. Session sandboxes intentionally deny all + // network access, so applying them here would make every approved + // remote job impossible under the default policy. The exact child is + // still bound to both the current authority and credential ledgers. + const linuxIdentity = process.platform === "linux" ? await processIdentity(process.pid) : undefined + if (process.platform === "linux" && !linuxIdentity) { + throw new Error("Could not establish the compute server identity for durable SSH transport ownership") + } + const wrapped = WindowsJobLauncher.wrap({ + file: spec[0]!, + args: spec.slice(1), + linuxOwner: linuxIdentity ? { pid: process.pid, identity: linuxIdentity } : undefined, + }) + let proc: ChildProcess + try { + proc = spawn(wrapped.file, wrapped.args, { + cwd: authority.workspace, + env: transport, + detached, + windowsHide: true, + stdio: [input?.fd ?? "ignore", output?.fd ?? "pipe", "pipe"], + }) + } catch (error) { + await cleanupGate(wrapped.release) + throw error + } + proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk)) + proc.stderr?.on("data", (chunk: Buffer) => errors.push(chunk)) + const done = new Promise<{ code: number | null; error?: string }>((resolve) => { + proc.once("error", (error) => resolve({ code: null, error: error.message })) + proc.once("exit", (code) => resolve({ code })) + }) + let identity: string | undefined + try { + identity = proc.pid ? await processIdentity(proc.pid) : undefined + if (!proc.pid || !identity) { + throw new Error("Could not establish durable ownership of the SSH control process") + } + // Deterministic regression hook for the pre-registration window. + // The Linux launcher must remain at its owner gate throughout this + // pause, so no connection reaches sshd before the injected failure. + if (process.env.OPENSCIENCE_TEST_HOME && process.env.OPENSCIENCE_SSH_TEST_REGISTRATION_FAILURE) { + await Bun.sleep(1_500) + throw new Error("Injected SSH control registration failure") + } + const registered = await CredentialProcessLedger.register({ + id: ledger, + kind: "compute", + pid: proc.pid, + detached, + identity, + projectID: authority.projectID, + sessionID: authority.sessionID, + authorityGeneration: authority.generation, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error("SSH control process exited before durable ownership was established") + // Windows and macOS release from inside durable registration after + // kernel ownership exists. Linux's owner-watching launcher stays at + // the pre-exec gate until the persisted process-group entry exists. + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, proc.pid) + } + } catch (error) { + const failures: unknown[] = [] + await CredentialProcessLedger.revoke({ id: ledger, kind: "compute" }).catch((failure) => + failures.push(failure), + ) + const stillOwned = proc.pid && identity ? await owns(proc.pid, identity) : true + if (stillOwned && proc.exitCode === null && proc.signalCode === null) { + await Shell.killTree(proc, { + detached, + exited: () => proc.exitCode !== null || proc.signalCode !== null, + }).catch((failure) => failures.push(failure)) + } + await cleanupGate(wrapped.release) + if (failures.length) { + throw new AggregateError([error, ...failures], "SSH control launch ownership cleanup failed") + } + throw error + } + try { + const result = await Promise.race([ + done, + Bun.sleep(options.timeout ?? 30_000).then(() => ({ code: null, error: "SSH operation timed out" })), + ]) + if (proc.exitCode === null && proc.signalCode === null) { + await Shell.killTree(proc, { + detached, + exited: () => proc.exitCode !== null || proc.signalCode !== null, + }) + } + await completeCredentialProcess(ledger) + const stderr = OpenScience.redactSecrets(Buffer.concat(errors).toString("utf8").trim()) + if (result.code !== 0) throw new Error(result.error || stderr || `SSH operation exited with ${result.code}`) + return { stdout: Buffer.concat(chunks), stderr } + } finally { + await cleanupGate(wrapped.release) + } + }), + ) + } finally { + await input?.close().catch(() => undefined) + await output?.close().catch(() => undefined) } } @@ -414,20 +775,43 @@ export namespace ComputeJobs { const key = keyOf(root, job.id) const settled = terminal.has(job.status) && - (job.target.kind !== "modal" || + (job.target.kind === "local" || lifecycle.recoverable || (lifecycle.delivery !== "pending" && lifecycle.resource === "closed")) if (settled || active.has(key) || claims.has(key)) return if (job.status === "queued" && Date.now() - Date.parse(job.created_at) < 5_000) return if (job.target.kind === "modal") { claims.add(key) + let lease: AsyncDisposable | undefined + let handedOff = false try { + lease = await FileLease.acquire(modalLeaseOf(root, job.id), 25).catch((error) => { + if (leaseBusy(error)) return undefined + throw error + }) + if (!lease) return const prior = await recovery(root, job) if (prior.retry > Date.now()) return const credentials = options.credentials ?? (await options.resolveCredentials?.().catch(() => undefined)) if (!credentials || !job.authority) return const provider = options.provider ?? ModalAdapter - active.set(key, { + const authorized = await currentAuthority(job.authority).then( + () => true, + async () => { + await cancel(job.id, { + ...options, + root, + workspace: scope.workspace, + credentials, + provider, + }).catch(() => undefined) + return false + }, + ) + if (!authorized) return + const current = await get(job.id, { root, workspace: scope.workspace }) + if (!current || current.status === "cancelled") return + await activate(key, { detached: false, authority: job.authority, root, @@ -472,7 +856,11 @@ export namespace ComputeJobs { `Modal recovery attempt ${attempt}/${recoveryLimit} deferred for ${recoveryDelay / 1000} seconds: ${message}`, ) }) - .finally(() => active.delete(key)) + .finally(async () => { + await deactivate(key) + await releaseLease(lease!) + }) + handedOff = true void managed.catch(() => undefined) if (!cleanup) await Promise.race([ @@ -484,39 +872,165 @@ export namespace ComputeJobs { ), ]) } finally { + if (lease && !handedOff) await releaseLease(lease) claims.delete(key) } return } - const marker = await Bun.file(exitOf(root, job.id)) - .text() - .catch(() => undefined) - const exit = marker?.trim().match(/^-?\d+$/) ? Number(marker.trim()) : undefined - if (job.target.kind === "local" && exit !== undefined) { - return { - id: job.id, - event: { type: "finish", outcome: exit === 0 ? "succeeded" : "failed" }, - value: { - completed_at: new Date().toISOString(), - exit_code: exit, - pid: undefined, - }, + if (job.target.kind === "ssh") { + claims.add(key) + let lease: AsyncDisposable | undefined + let handedOff = false + try { + lease = await FileLease.acquire(sshLeaseOf(root, job.id), 25).catch((error) => { + if (leaseBusy(error)) return undefined + throw error + }) + if (!lease) return + const prior = await recovery(root, job) + if (prior.retry > Date.now()) return + await activate(key, { + detached: false, + authority: job.authority!, + root, + workspace: scope.workspace, + id: job.id, + host: job.ssh?.host, + }) + const managed = recoverSsh(job, scope) + .then(async () => { + if (!job.recovery_attempts && !job.recovery_retry_at) return + await change(root, (jobs) => { + const stored = jobs.find((item) => item.id === job.id) + if (!stored) return + stored.recovery_attempts = undefined + stored.recovery_retry_at = undefined + }) + }) + .catch(async (error) => { + const attempt = prior.attempt + 1 + const delay = Math.min(5 * 60_000, recoveryDelay * 2 ** Math.min(attempt - 1, 5)) + await change(root, (jobs) => { + const stored = jobs.find((item) => item.id === job.id) + if (!stored) return + stored.recovery_attempts = attempt + stored.recovery_retry_at = new Date(Date.now() + delay).toISOString() + }) + await event( + root, + job.id, + `SSH recovery attempt ${attempt} deferred for ${delay / 1000} seconds: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + .finally(async () => { + await deactivate(key) + await releaseLease(lease!) + }) + handedOff = true + void managed.catch(() => undefined) + } finally { + if (lease && !handedOff) await releaseLease(lease) + claims.delete(key) } + return } - if (job.target.kind === "local" && job.pid && alive(job.pid)) return - return { - id: job.id, - event: { type: "interrupt" }, - value: { - completed_at: new Date().toISOString(), - exit_code: null, - pid: undefined, - error: - job.target.kind === "ssh" - ? "The app connection ended before this remote job reported a result. Check the remote scheduler before rerunning it." - : "The job process ended before it could report a result.", - }, + if (job.target.kind === "local") { + claims.add(key) + let lease: AsyncDisposable | undefined + let handedOff = false + try { + lease = await FileLease.acquire(localLeaseOf(root, job.id), 25).catch((error) => { + if (leaseBusy(error)) return undefined + throw error + }) + if (!lease) return + const current = await get(job.id, { root, workspace: scope.workspace }) + if (!current || terminal.has(current.status)) return + const exit = await localExit(root, current.id) + if (exit !== undefined) { + await change(root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const finished = move( + jobs[index]!, + { type: "finish", outcome: exit === 0 ? "succeeded" : "failed" }, + { + completed_at: new Date().toISOString(), + exit_code: exit, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = move(finished, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + if (!current.pid || !(await owns(current.pid, current.process_identity))) { + // A normal wrapper writes its exit marker before the owned + // supervisor disappears. Re-read after the identity check, + // then classify a genuinely markerless death while still + // holding the one durable local lifecycle lease. + const reported = await localExit(root, current.id) + await change(root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const draft = + reported === undefined + ? move( + jobs[index]!, + { type: "interrupt" }, + { + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + error: "The job process ended before it could report a result.", + }, + ) + : move( + jobs[index]!, + { type: "finish", outcome: reported === 0 ? "succeeded" : "failed" }, + { + completed_at: new Date().toISOString(), + exit_code: reported, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = reported === undefined ? draft : move(draft, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + if (!current.authority) return + await activate(key, { + dataRootOwner: + process.platform === "win32" + ? undefined + : { pid: current.pid, identity: current.process_identity! }, + detached: process.platform !== "win32", + authority: current.authority, + root, + workspace: scope.workspace, + id: current.id, + }) + const managed = recoverLocal(current, scope).finally(async () => { + try { + await deactivate(key) + } finally { + await releaseLease(lease!) + } + }) + handedOff = true + void managed.catch(() => undefined) + } finally { + claims.delete(key) + if (lease && !handedOff) await releaseLease(lease) + } + return } + return }, ), ) @@ -687,11 +1201,13 @@ export namespace ComputeJobs { file: spec.argv[0]!, args: spec.argv.slice(1), workspace: authority.writable, + readable: authority.readable, unreadable: OpenScience.kernelSensitivePaths(), options: authority.sandbox, }) return { argv: [planned.file, ...planned.args], + temporary: planned.temporary, sandbox: { requested: authority.sandbox.enabled, enforced: planned.sandboxed, @@ -709,12 +1225,14 @@ export namespace ComputeJobs { file: Shell.acceptable(), args: ["-lc", wrapped], workspace: authority.writable, + readable: authority.readable, extraWritable: [exitOf(scope.root, job.id)], unreadable: OpenScience.kernelSensitivePaths(), options: authority.sandbox, }) return { argv: [planned.file, ...planned.args], + temporary: planned.temporary, sandbox: { requested: authority.sandbox.enabled, enforced: planned.sandboxed, @@ -730,23 +1248,29 @@ export namespace ComputeJobs { cwd: string, authority: ExecutionAuthority.Decision, ): Promise { + await currentAuthority(authority) const planned = Sandbox.wrapArgv({ file: argv[0]!, args: argv.slice(1), workspace: authority.writable, + readable: authority.readable, unreadable: OpenScience.kernelSensitivePaths(), options: authority.sandbox, }) - const proc = Bun.spawn([planned.file, ...planned.args], { - cwd, - env: await OpenScience.subprocessEnv(process.env), - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }) - const [code, text] = await Promise.all([proc.exited, new Response(proc.stdout).text()]) - if (code !== 0) return - return text.trim() || undefined + try { + const proc = Bun.spawn([planned.file, ...planned.args], { + cwd, + env: OpenScience.kernelEnv(process.env), + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }) + const [code, text] = await Promise.all([proc.exited, new Response(proc.stdout).text()]) + if (code !== 0) return + return text.trim() || undefined + } finally { + Sandbox.cleanup(planned) + } } function inside(root: string, file: string): string | undefined { @@ -959,12 +1483,14 @@ export namespace ComputeJobs { ProvenanceEnvelope.output({ kind: "artifact", label: artifact.path, - artifactID: artifact.path, + artifactID: artifact.artifact_id, path: artifact.path, sha256: artifact.sha256, size: artifact.size, + versionID: artifact.version_id, + version: artifact.version, createdAt: artifact.modified_at, - versionReason: "not_versioned", + versionReason: artifact.version_id ? undefined : "not_versioned", }), ), ...(job.checkpoint @@ -972,12 +1498,14 @@ export namespace ComputeJobs { ProvenanceEnvelope.output({ kind: "checkpoint", label: job.checkpoint.path, - artifactID: job.checkpoint.path, + artifactID: job.checkpoint.artifact_id, path: job.checkpoint.path, sha256: job.checkpoint.sha256, size: job.checkpoint.size, + versionID: job.checkpoint.version_id, + version: job.checkpoint.version, createdAt: job.checkpoint.modified_at, - versionReason: "not_versioned", + versionReason: job.checkpoint.version_id ? undefined : "not_versioned", }), ] : []), @@ -1012,16 +1540,59 @@ export namespace ComputeJobs { }) } + async function versionCapture( + job: Job, + value: Pick, + ): Promise> { + const sessionID = job.session_id + const projectID = job.authority?.projectID + if (!sessionID || !projectID || !job.cwd) return value + + const unique = new Map() + for (const item of [...(value.artifacts ?? []), ...(value.checkpoint ? [value.checkpoint] : [])]) { + unique.set(item.path, item) + } + const saved = new Map() + await Promise.all( + [...unique.values()].map(async (item) => { + const source = path.resolve(job.cwd!, item.path) + const version = await ArtifactStore.save({ + projectID, + sessionID, + sourcePath: item.path, + filename: path.basename(item.path), + kind: item.path === value.checkpoint?.path ? "compute-checkpoint" : "compute-output", + content: Bun.file(source), + captureQuality: "exact", + title: path.basename(item.path), + }) + if (version.current.sha256 !== item.sha256 || version.current.size !== item.size) { + throw new Error(`Immutable artifact verification failed for ${item.path}`) + } + saved.set(item.path, { + ...item, + artifact_id: version.id, + version_id: version.current.id, + version: version.current.version, + }) + }), + ) + return { + artifacts: value.artifacts?.map((item) => saved.get(item.path) ?? item), + checkpoint: value.checkpoint ? (saved.get(value.checkpoint.path) ?? value.checkpoint) : undefined, + } + } + async function capture(job: Job): Promise> { const cwd = path.resolve(job.cwd ?? process.cwd()) const [found, checkpoint] = await Promise.all([ artifacts(cwd, job.artifact_patterns ?? []), job.checkpoint_path ? fingerprint(cwd, job.checkpoint_path) : undefined, ]) - return { + return versionCapture(job, { artifacts: found, checkpoint, - } + }) } async function captureModal( @@ -1037,26 +1608,416 @@ export namespace ComputeJobs { const checkpoint = job.checkpoint_path ? found.find((item) => item.path === job.checkpoint_path!.split(path.sep).join("/")) : undefined - return { + return versionCapture(job, { artifacts: found.filter((item) => patterns.some((pattern) => pattern.match(item.path))), checkpoint, + }) + } + + async function sshSpec(job: Job, scope: Scope, files?: SshAdapter.Upload[]): Promise { + if (!job.ssh || !job.cwd) throw new Error(`SSH job ${job.id} is missing its durable dispatch specification`) + const key = await SecretFile.key(path.join(scope.root, "ssh-control.key")) + return { + id: job.id, + owner: crypto.createHmac("sha256", key).update(`openscience-ssh-v1\0${job.id}\0${job.ssh.root}`).digest("hex"), + root: job.ssh.root, + cwd: job.ssh.cwd, + command: job.command, + scheduler: job.scheduler, + resources: job.resources, + modules: job.modules, + container: job.container, + outputs: [...(job.artifact_patterns ?? []), ...(job.checkpoint_path ? [job.checkpoint_path] : [])], + uploads: + files ?? + job.ssh.uploads.map((file) => ({ + ...file, + canonical: path.resolve(job.cwd!, file.path), + })), + } + } + + async function stageSsh(job: Job, scope: Scope, files?: SshAdapter.Upload[]) { + if (!job.ssh || !job.authority) throw new Error(`SSH job ${job.id} has no staging authority`) + await fs.mkdir(logsOf(scope.root), { recursive: true }) + const spec = await sshSpec(job, scope, files) + const archive = await SshAdapter.archive(spec, logsOf(scope.root)) + try { + await event( + scope.root, + job.id, + `Staging ${spec.uploads.length} verified input file${spec.uploads.length === 1 ? "" : "s"} on ${job.target_label}`, + ) + await sshRun(scope, job, job.ssh.host, job.authority, SshAdapter.receive(spec), { + stdin: archive, + timeout: 120_000, + }) + } finally { + await fs.rm(archive, { force: true }) + } + } + + async function submitSsh(job: Job, scope: Scope) { + if (!job.ssh || !job.authority) throw new Error(`SSH job ${job.id} has no submission authority`) + const result = await sshRun( + scope, + job, + job.ssh.host, + job.authority, + SshAdapter.invoke(await sshSpec(job, scope), "submit"), + { timeout: 30_000 }, + ) + // Test-only crash point: emulate the local owner disappearing after the + // remote scheduler accepted and durably named the resource, but before + // this process can publish remote_id into jobs.json. A fresh process must + // recover through the remote idempotency record without a second launch. + if (process.env.OPENSCIENCE_TEST_HOME && process.env.OPENSCIENCE_SSH_TEST_KILLPOINT === "after-accept") + process.exit(86) + const submitted = SshAdapter.parse<{ remote_id: string; reattached: boolean }>(result.stdout) + if (!/^(?:pid|slurm|pbs):[^\s]+$/.test(submitted.remote_id)) { + throw new Error("SSH scheduler returned an invalid remote job id") + } + const current = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + if (terminal.has(jobs[index]!.status)) return jobs[index]! + const starting = + jobs[index]!.lifecycle?.execution === "queued" ? move(jobs[index]!, { type: "start" }) : jobs[index]! + const running = starting.lifecycle?.execution === "starting" ? move(starting, { type: "run" }) : starting + jobs[index] = Job.parse({ + ...running, + remote_id: submitted.remote_id, + started_at: running.started_at ?? new Date().toISOString(), + provenance: provenance(running), + }) + return jobs[index]! + }) + await event( + scope.root, + job.id, + submitted.reattached + ? `Reattached to ${submitted.remote_id}` + : `Submitted ${submitted.remote_id} to ${job.target_label}`, + ) + return current + } + + async function startSsh(job: Job, scope: Scope, files: SshAdapter.Upload[]) { + await stageSsh(job, scope, files) + return submitSsh(job, scope) + } + + async function sshLog(job: Job, scope: Scope) { + if (!job.ssh || !job.authority || !job.remote_id) return + const value = await sshRun( + scope, + job, + job.ssh.host, + job.authority, + SshAdapter.invoke(await sshSpec(job, scope), "log", "262144"), + ) + await snapshot(path.join(logsOf(scope.root), `${job.id}.log`), value.stdout.toString("utf8")) + } + + function missingSshOutputs(job: Job, found: Artifact[]) { + const expected = [...(job.artifact_patterns ?? []), ...(job.checkpoint_path ? [job.checkpoint_path] : [])] + return expected.filter((pattern) => { + const glob = new Bun.Glob(pattern.split(path.sep).join("/")) + return !found.some((file) => glob.match(file.path.split(path.sep).join("/"))) + }) + } + + async function releaseSsh(job: Job, scope: Scope, authorize = true) { + if (!job.ssh || !job.authority) throw new Error(`SSH job ${job.id} has no releasable remote workspace`) + await sshRun(scope, job, job.ssh.host, job.authority, SshAdapter.invoke(await sshSpec(job, scope), "release"), { + timeout: 30_000, + authorize, + }) + await event(scope.root, job.id, `Released remote workspace ${job.ssh.root}`) + } + + async function harvestSsh(job: Job, scope: Scope) { + if (!job.ssh || !job.authority || !job.cwd) throw new Error(`SSH job ${job.id} has no recoverable output`) + const archive = path.join(logsOf(scope.root), `${job.id}.${crypto.randomUUID()}.outputs.tar`) + try { + await sshRun(scope, job, job.ssh.host, job.authority, SshAdapter.invoke(await sshSpec(job, scope), "harvest"), { + stdout: archive, + timeout: 300_000, + }) + const delivered = Artifact.array().parse(await SshAdapter.deliver(archive, job.cwd)) + const missing = missingSshOutputs(job, delivered) + if (missing.length) { + throw new Error( + `SSH job did not produce declared output${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`, + ) + } + const checkpoint = job.checkpoint_path + ? delivered.find((item) => item.path === job.checkpoint_path!.split(path.sep).join("/")) + : undefined + return versionCapture(job, { + artifacts: delivered.filter((item) => + (job.artifact_patterns ?? []).some((pattern) => new Bun.Glob(pattern).match(item.path)), + ), + checkpoint, + }) + } finally { + await fs.rm(archive, { force: true }) + } + } + + async function finishSsh(job: Job, scope: Scope, code: number) { + const collecting = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const current = jobs[index]! + if (terminal.has(current.status)) return current + const finished = move( + current, + { type: "finish", outcome: code === 0 ? "succeeded" : "failed" }, + { + completed_at: new Date().toISOString(), + exit_code: code, + }, + ) + const expected = (finished.artifact_patterns?.length ?? 0) > 0 || !!finished.checkpoint_path + const next = expected ? move(finished, { type: "collect" }) : finished + jobs[index] = Job.parse({ ...next, provenance: provenance(next) }) + return jobs[index]! + }) + const expected = (collecting.artifact_patterns?.length ?? 0) > 0 || !!collecting.checkpoint_path + if (!expected) { + const released = await releaseSsh(collecting, scope).then( + () => true, + async (error) => { + await event( + scope.root, + job.id, + `Remote workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + }, + ) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const current = jobs[index]! + const lifecycle = released ? move(current, { type: "close" }) : move(current, { type: "lose" }) + jobs[index] = Job.parse({ ...lifecycle, provenance: provenance(lifecycle) }) + return jobs[index]! + }) + } + const captured = await harvestSsh(collecting, scope).catch(async (error) => { + const message = error instanceof Error ? error.message : String(error) + await event(scope.root, job.id, `SSH output recovery failed: ${message}`) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) return + const current = jobs[index]! + if (current.lifecycle?.delivery !== "pending") return + const failed = move(current, { type: "delivery_fail", message }, { capture_error: message }) + const retained = move(failed, { type: "lose" }) + jobs[index] = Job.parse({ ...retained, provenance: provenance(retained) }) + }) + return undefined + }) + if (!captured) return get(job.id, { root: scope.root, workspace: scope.workspace }) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const current = jobs[index]! + if (current.lifecycle?.delivery !== "pending") return + const delivered = move(current, { type: "deliver" }) + jobs[index] = Job.parse({ + ...delivered, + ...captured, + capture_error: undefined, + provenance: provenance(delivered), + }) + }) + const delivered = await get(job.id, { root: scope.root, workspace: scope.workspace }) + if (!delivered) throw new Error(`Compute job ${job.id} was not found`) + const released = await releaseSsh(delivered, scope).then( + () => true, + async (error) => { + await event( + scope.root, + job.id, + `Remote workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + }, + ) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const current = jobs[index]! + const next = released ? move(current, { type: "close" }) : move(current, { type: "lose" }) + jobs[index] = Job.parse({ ...next, provenance: provenance(next) }) + return jobs[index]! + }) + } + + async function recoverSsh(job: Job, scope: Scope) { + if (!job.ssh || !job.authority) return + const allowed = await currentAuthority(job.authority).then( + () => true, + () => false, + ) + if (!allowed) { + await cancelSsh(job, scope) + return + } + if (!job.remote_id) { + await submitSsh(job, scope).catch(async () => { + await stageSsh(job, scope) + await submitSsh(job, scope) + }) + return + } + const lifecycle = job.lifecycle ?? ComputeLifecycle.from(job.status) + if (terminal.has(job.status) && lifecycle.delivery !== "pending") { + const checked = await sshRun( + scope, + job, + job.ssh.host, + job.authority, + SshAdapter.inspect(await sshSpec(job, scope)), + ) + const exists = SshAdapter.parse<{ exists: boolean }>(checked.stdout).exists + if (!exists) { + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) return + const current = jobs[index]! + const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current + const closed = abandoned.lifecycle?.resource === "closed" ? abandoned : move(abandoned, { type: "close" }) + jobs[index] = Job.parse({ ...closed, cleanup_error: undefined, provenance: provenance(closed) }) + }) + await event(scope.root, job.id, "Confirmed that the remote workspace was already released") + return + } } + await sshLog(job, scope).catch(() => undefined) + const response = await sshRun( + scope, + job, + job.ssh.host, + job.authority, + SshAdapter.invoke(await sshSpec(job, scope), "status", job.remote_id), + ) + const state = SshAdapter.parse(response.stdout) + if (state.state === "queued" || state.state === "running") return + if (state.state === "unknown") { + await event(scope.root, job.id, state.detail ?? "Remote scheduler state is temporarily unavailable") + return + } + if (state.state === "cancelled") { + await cancelSsh(job, scope) + return + } + if (state.code === undefined) throw new Error("SSH job completed without an exit code") + await finishSsh(job, scope, state.code) + } + + async function cancelSsh(job: Job, scope: Scope) { + if (!job.ssh || !job.authority) throw new Error(`SSH job ${job.id} has no cancellable remote resource`) + await using operation = await FileLease.acquire(sshOperationOf(scope.root, job.id)) + const current = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const stored = jobs[index]! + if (terminal.has(stored.status)) return stored + const cancelled = move(stored, { type: "cancel" }, { completed_at: new Date().toISOString(), exit_code: null }) + jobs[index] = Job.parse({ ...cancelled, provenance: provenance(cancelled) }) + return jobs[index]! + }) + const remote = await (async () => { + if (!current.remote_id) return { closed: true, error: undefined } + const spec = await sshSpec(current, scope) + const checked = await sshRun(scope, current, current.ssh!.host, current.authority!, SshAdapter.inspect(spec), { + timeout: 30_000, + authorize: false, + }) + if (!SshAdapter.parse<{ exists: boolean }>(checked.stdout).exists) return { closed: true, error: undefined } + const cancelled = await sshRun( + scope, + current, + current.ssh!.host, + current.authority!, + SshAdapter.invoke(spec, "cancel", current.remote_id), + { timeout: 30_000, authorize: false }, + ).then((value) => SshAdapter.parse<{ cancelled: boolean }>(value.stdout).cancelled) + if (!cancelled) return { closed: false, error: "Remote scheduler did not confirm cancellation" } + await releaseSsh(current, scope, false) + return { closed: true, error: undefined } + })().catch((error) => ({ closed: false, error: error instanceof Error ? error.message : String(error) })) + if (remote.error) await event(scope.root, current.id, `Remote cancellation pending: ${remote.error}`) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0) throw new Error(`Compute job ${current.id} was not found`) + const stored = jobs[index]! + const abandoned = stored.lifecycle?.recoverable ? move(stored, { type: "abandon" }) : stored + const lifecycle = remote.closed ? move(abandoned, { type: "close" }) : move(abandoned, { type: "lose" }) + jobs[index] = Job.parse({ + ...lifecycle, + cleanup_error: remote.closed + ? undefined + : `Remote cancellation was not confirmed. ${remote.error ?? "Retry cancellation."}`, + provenance: provenance(lifecycle), + }) + return jobs[index]! + }) } export async function probe(host: Host): Promise { const parsed = Host.parse(host) const started = performance.now() + const scanned = await SshAdapter.scan(parsed).catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })) + if ("error" in scanned) { + return Probe.parse({ + ok: false, + host: parsed.label, + latency_ms: Math.round(performance.now() - started), + python: false, + gpu: false, + slurm: false, + pbs: false, + error: scanned.error, + }) + } + if (parsed.fingerprint && parsed.fingerprint !== scanned.fingerprint) { + return Probe.parse({ + ok: false, + host: parsed.label, + latency_ms: Math.round(performance.now() - started), + python: false, + gpu: false, + slurm: false, + pbs: false, + fingerprint: scanned.fingerprint, + host_key: scanned.host_key, + error: `SSH host key changed: expected ${parsed.fingerprint}, received ${scanned.fingerprint}`, + }) + } const script = [ "printf 'connected=1\\n'", "printf 'hostname='; hostname 2>/dev/null || true", "command -v python3 >/dev/null 2>&1 && printf 'python=1\\n' || true", + "command -v bash >/dev/null 2>&1 && printf 'bash=1\\n' || true", "command -v nvidia-smi >/dev/null 2>&1 && printf 'gpu=1\\n' || true", - "command -v sbatch >/dev/null 2>&1 && printf 'slurm=1\\n' || true", - "command -v qsub >/dev/null 2>&1 && printf 'pbs=1\\n' || true", + "command -v sbatch >/dev/null 2>&1 && command -v squeue >/dev/null 2>&1 && command -v sacct >/dev/null 2>&1 && command -v scancel >/dev/null 2>&1 && printf 'slurm=1\\n' || true", + "command -v qsub >/dev/null 2>&1 && command -v qstat >/dev/null 2>&1 && command -v qdel >/dev/null 2>&1 && printf 'pbs=1\\n' || true", ].join("; ") - const argv = ssh(parsed, script) + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-ssh-probe-")) + const known = await SshAdapter.known({ ...parsed, ...scanned }, temporary) + const argv = SshAdapter.argv({ ...parsed, ...scanned }, known, script) + const agent = process.env.SSH_AUTH_SOCK const proc = spawn(argv[0]!, argv.slice(1), { - env: await OpenScience.subprocessEnv(process.env), + // The broker owns SSH authentication, so it passes only the agent + // socket—not private-key files or arbitrary shell credentials. + env: agent ? { ...OpenScience.kernelEnv(process.env), SSH_AUTH_SOCK: agent } : OpenScience.kernelEnv(process.env), windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }) @@ -1078,17 +2039,37 @@ export namespace ComputeJobs { exited: () => proc.exitCode !== null, }) } + await fs.rm(temporary, { recursive: true, force: true }) const text = Buffer.concat(output).toString("utf8") - const error = result.error || (result.code === 0 ? undefined : Buffer.concat(errors).toString("utf8").trim()) + const connected = result.code === 0 && text.includes("connected=1") + const python = text.includes("python=1") + const bash = text.includes("bash=1") + const slurm = text.includes("slurm=1") + const pbs = text.includes("pbs=1") + const missing = [ + !python ? "Python 3" : undefined, + !bash ? "Bash" : undefined, + parsed.scheduler === "slurm" && !slurm ? "Slurm (sbatch, squeue, sacct, scancel)" : undefined, + parsed.scheduler === "pbs" && !pbs ? "PBS (qsub, qstat, qdel)" : undefined, + ].filter((value): value is string => !!value) + const transportError = + result.error || (result.code === 0 ? undefined : Buffer.concat(errors).toString("utf8").trim()) + const error = + transportError || + (connected && missing.length + ? `Dispatch prerequisites missing on ${parsed.label}: ${missing.join(", ")}` + : undefined) return Probe.parse({ - ok: result.code === 0 && text.includes("connected=1"), + ok: connected && missing.length === 0, host: parsed.label, latency_ms: Math.round(performance.now() - started), hostname: text.match(/^hostname=(.+)$/m)?.[1]?.trim(), - python: text.includes("python=1"), + python, gpu: text.includes("gpu=1"), - slurm: text.includes("slurm=1"), - pbs: text.includes("pbs=1"), + slurm, + pbs, + fingerprint: scanned.fingerprint, + host_key: scanned.host_key, error: error || undefined, }) } @@ -1099,63 +2080,131 @@ export namespace ComputeJobs { scope: Scope, authority: ExecutionAuthority.Decision, launch: Launch, + ready?: () => void, ): Promise { await fs.mkdir(logsOf(scope.root), { recursive: true }) const log = path.join(logsOf(scope.root), `${job.id}.log`) const output = await fs.open(log, "a", 0o600) - const env = await OpenScience.subprocessEnv(process.env) - const queued = (await read(scope.root)).find((item) => item.id === job.id) - if (queued?.status === "cancelled") { - await output.close() - active.delete(keyOf(scope.root, job.id)) - return - } const detached = process.platform !== "win32" - const proc = spawn(launch.argv[0]!, launch.argv.slice(1), { - cwd: host ? authority.workspace : job.cwd, - env, - detached, - windowsHide: true, - stdio: ["ignore", output.fd, output.fd], - }) - const result = new Promise<{ code: number | null; error?: string }>((resolve) => { - proc.once("error", (error) => resolve({ code: null, error: error.message })) - proc.once("exit", (code) => resolve({ code })) - }) - const key = keyOf(scope.root, job.id) - active.set(key, { - process: proc, - detached, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - host, - }) - await output.close() - const started = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === job.id) - if (index < 0 || terminal.has(jobs[index]!.status)) return false - const draft = move( - jobs[index]!, - { type: "run" }, - { - started_at: new Date().toISOString(), - pid: proc.pid, - }, + const ledgerID = credentialProcessID(scope.root, job.id) + let launched: + | { + proc: ChildProcess + result: Promise<{ code: number | null; error?: string }> + key: string + } + | undefined + try { + launched = await AuthoritySignal.exclusive(() => + OpenScience.withSubprocessEnv(process.env, async (env) => { + await currentAuthority(authority) + const queued = (await read(scope.root)).find((item) => item.id === job.id) + if (!queued || terminal.has(queued.status)) return + const linuxIdentity = process.platform === "linux" ? await processIdentity(process.pid) : undefined + if (process.platform === "linux" && !linuxIdentity) { + throw new Error(`Could not establish the compute server identity for durable launch registration`) + } + const wrapped = WindowsJobLauncher.wrap({ + file: launch.argv[0]!, + args: launch.argv.slice(1), + linuxOwner: linuxIdentity ? { pid: process.pid, identity: linuxIdentity } : undefined, + }) + const proc = spawn(wrapped.file, wrapped.args, { + cwd: host ? authority.workspace : job.cwd, + env, + detached, + windowsHide: true, + stdio: ["ignore", output.fd, output.fd], + }) + proc.once("exit", () => Sandbox.cleanup(launch)) + proc.once("error", () => Sandbox.cleanup(launch)) + const result = new Promise<{ code: number | null; error?: string }>((resolve) => { + proc.once("error", (error) => resolve({ code: null, error: error.message })) + proc.once("exit", (code) => resolve({ code })) + }) + const identity = proc.pid ? await processIdentity(proc.pid) : undefined + try { + if (!proc.pid || !identity) { + if (proc.exitCode !== null || proc.signalCode !== null) { + throw new Error("Compute child exited before durable process-group ownership could be established") + } + throw new Error("Could not establish a safe identity for the credential-bearing compute child") + } else { + const registered = await CredentialProcessLedger.register({ + id: ledgerID, + kind: "compute", + pid: proc.pid, + detached, + identity, + projectID: authority.projectID, + sessionID: authority.sessionID, + authorityGeneration: authority.generation, + windowsRelease: wrapped.release, + }) + if (!registered) { + throw new Error("Compute child exited before durable process-group ownership could be established") + } + } + const key = keyOf(scope.root, job.id) + await activate(key, { + process: proc, + dataRootOwner: process.platform === "win32" ? undefined : { pid: proc.pid, identity }, + detached, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + host, + }) + const started = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return false + const draft = move( + jobs[index]!, + { type: "run" }, + { + started_at: new Date().toISOString(), + pid: proc.pid, + process_identity: identity, + }, + ) + jobs[index] = Job.parse({ ...draft, provenance: provenance(draft) }) + return true + }) + if (!started) { + await Shell.killTree(proc, { detached, exited: () => proc.exitCode !== null }) + await deactivate(key) + await completeCredentialProcess(ledgerID) + ready?.() + return + } + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, proc.pid) + } + ready?.() + return { proc, result, key } + } catch (error) { + await Shell.killTree(proc, { detached, exited: () => proc.exitCode !== null }) + await completeCredentialProcess(ledgerID) + throw error + } + }), ) - jobs[index] = Job.parse({ ...draft, provenance: provenance(draft) }) - return true - }) - if (!started) { - await Shell.killTree(proc, { - detached, - exited: () => proc.exitCode !== null, - }) - active.delete(key) + } catch (error) { + await output.close().catch(() => undefined) + Sandbox.cleanup(launch) + throw error + } + await output.close() + if (!launched) { + await deactivate(keyOf(scope.root, job.id)) + Sandbox.cleanup(launch) + ready?.() return } + const { proc, result, key } = launched const completed = await result + await completeCredentialProcess(ledgerID) const captureResult = host ? undefined : await capture(job) @@ -1176,13 +2225,15 @@ export namespace ComputeJobs { { completed_at: new Date().toISOString(), exit_code: completed.code, + pid: undefined, + process_identity: undefined, error: completed.error, ...captureResult, }, ) const closed = move(draft, { type: "close" }) jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) - }).finally(() => active.delete(key)) + }).finally(() => deactivate(key)) } async function completeModal( @@ -1463,43 +2514,98 @@ export namespace ComputeJobs { export async function retry(id: string, options: Options = {}): Promise { const scope = await scoped(options) const key = keyOf(scope.root, id) - if (active.has(key)) throw new Error(`Compute job ${id} already has an active recovery`) - const provider = options.provider ?? ModalAdapter - const job = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const current = jobs[index]! - if (current.target.kind !== "modal" || !current.modal || !current.cwd || !current.authority) { - throw new Error(`Compute job ${id} has no recoverable Modal output`) + const stored = await get(id, { root: scope.root, workspace: scope.workspace }) + if (stored?.target.kind === "ssh") { + if (active.has(key)) throw new Error(`Compute job ${id} already has an active recovery`) + if (!stored.ssh || !stored.authority || !stored.lifecycle?.recoverable || !terminal.has(stored.status)) { + throw new Error(`Compute job ${id} has no recoverable SSH output`) } - if (!terminal.has(current.status) || !current.lifecycle?.recoverable) { - throw new Error(`Compute job ${id} has no recoverable Modal output`) + await using operation = await FileLease.acquire(sshOperationOf(scope.root, id)) + await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) + const retrying = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const draft = move(jobs[index]!, { type: "retry_delivery" }, { capture_error: undefined, error: undefined }) + jobs[index] = Job.parse({ ...draft, provenance: provenance(draft) }) + return jobs[index]! + }) + await finishSsh(retrying, scope, retrying.exit_code ?? 1) + return (await get(id, { root: scope.root, workspace: scope.workspace }))! + } + // A Modal delivery failure can become visible just before its current + // owner releases the in-memory runtime. The durable lease is the source of + // truth across both this process and sibling servers: wait for that owner + // instead of rejecting an explicit retry in the handoff window. + await using operation = await FileLease.acquire(modalOperationOf(scope.root, id)) + const lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) + let handedOff = false + try { + const provider = options.provider ?? ModalAdapter + const job = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + if (current.target.kind !== "modal" || !current.modal || !current.cwd || !current.authority) { + throw new Error(`Compute job ${id} has no recoverable Modal output`) + } + if (!terminal.has(current.status) || !current.lifecycle?.recoverable) { + throw new Error(`Compute job ${id} has no recoverable Modal output`) + } + const draft = move(current, { type: "retry_delivery" }, { error: undefined, capture_error: undefined }) + const updated = Job.parse({ ...draft, provenance: provenance(draft) }) + jobs[index] = updated + return updated + }) + const context = await modalContext(options, "Enable Modal before retrying output delivery") + await activate(key, { + detached: false, + authority: job.authority!, + root: scope.root, + workspace: scope.workspace, + id: job.id, + modal: context, + provider, + }) + const managed = recoverModal(job, scope, context, provider) + .catch((error) => failModal(job, scope, context, error, provider)) + .finally(async () => { + await deactivate(key) + await releaseLease(lease) + }) + handedOff = true + void managed.catch(() => undefined) + return job + } finally { + if (!handedOff) { + await deactivate(key) + await releaseLease(lease) } - const draft = move(current, { type: "retry_delivery" }, { error: undefined, capture_error: undefined }) - const updated = Job.parse({ ...draft, provenance: provenance(draft) }) - jobs[index] = updated - return updated - }) - const context = await modalContext(options, "Enable Modal before retrying output delivery") - active.set(key, { - detached: false, - authority: job.authority!, - root: scope.root, - workspace: scope.workspace, - id: job.id, - modal: context, - provider, - }) - void recoverModal(job, scope, context, provider) - .catch((error) => failModal(job, scope, context, error, provider)) - .finally(() => active.delete(key)) - return job + } } export async function release(id: string, options: Options = {}): Promise { const scope = await scoped(options) const key = keyOf(scope.root, id) if (active.has(key)) throw new Error(`Compute job ${id} still has an active recovery`) + const stored = await get(id, { root: scope.root, workspace: scope.workspace }) + if (stored?.target.kind === "ssh") { + if (!terminal.has(stored.status)) throw new Error(`Cancel compute job ${id} before releasing its resources`) + if (stored.status === "cancelled" && stored.lifecycle?.resource !== "closed") return cancelSsh(stored, scope) + await using operation = await FileLease.acquire(sshOperationOf(scope.root, id)) + await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) + await releaseSsh(stored, scope) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current + const closed = current.lifecycle?.resource === "closed" ? abandoned : move(abandoned, { type: "close" }) + jobs[index] = Job.parse({ ...closed, cleanup_error: undefined, provenance: provenance(closed) }) + return jobs[index]! + }) + } + await using operation = await FileLease.acquire(modalOperationOf(scope.root, id)) + await using lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) const job = await get(id, { root: scope.root, workspace: scope.workspace }) if (!job) throw new Error(`Compute job ${id} was not found`) if (job.target.kind !== "modal" || !job.modal || !job.cwd) { @@ -1528,23 +2634,42 @@ export namespace ComputeJobs { return released } - export async function plan(input: Request, options: Options = {}): Promise { + export async function plan(input: Request, options: Options = {}): Promise { const parsed = Request.parse(input) - if (parsed.target.kind !== "modal") throw new Error("Only Modal jobs require an approval plan") + if (parsed.target.kind === "local") throw new Error("Local jobs do not require a remote approval plan") const scope = await scoped(options) const authority = await ExecutionAuthority.require({ projectID: Instance.project.id, sessionID: parsed.sessionID, capability: "remote_job", }) - const requested = parsed.cwd ? path.resolve(authority.workspace, parsed.cwd) : authority.workspace + const requested = authority.workspace const cwd = await Filesystem.canonical(requested) const info = cwd ? await fs.stat(cwd).catch(() => undefined) : undefined if (!cwd || !info?.isDirectory() || !Filesystem.contains(authority.workspace, cwd)) { - throw new Error(`Modal working directory must be inside the session workspace: ${parsed.cwd ?? requested}`) + throw new Error(`Remote staging directory must be inside the session workspace: ${requested}`) } if (scope.workspace !== authority.workspace) throw new Error("Compute project does not match the session workspace") - return (await modal(parsed, cwd, options.modal)).plan + if (parsed.target.kind === "modal") return (await modal(parsed, cwd, options.modal)).plan + if (parsed.target.kind !== "ssh") throw new Error("Unsupported remote compute target") + const hostID = parsed.target.host_id + const host = options.hosts?.find((item) => item.id === hostID) + if (!host) throw new Error("The selected SSH compute profile was not found") + await outputs(cwd, parsed.artifacts ?? [], parsed.checkpoint) + return ( + await SshPlan.prepare({ + id: "approved-job", + command: parsed.command, + resources: parsed.resources, + modules: parsed.modules, + container: parsed.container, + cwd, + remoteCwd: parsed.cwd, + uploads: parsed.uploads ?? [], + outputs: [...(parsed.artifacts ?? []), ...(parsed.checkpoint ? [parsed.checkpoint] : [])], + host, + }) + ).plan } export async function start(input: Request, options: Options = {}): Promise { @@ -1560,20 +2685,38 @@ export namespace ComputeJobs { }) if (scope.workspace !== authority.workspace) throw new Error("Compute project does not match the session workspace") const requested = parsed.cwd ? path.resolve(authority.workspace, parsed.cwd) : authority.workspace - const cwd = host ? parsed.cwd || host.workdir : await Filesystem.canonical(requested) + const cwd = host ? authority.workspace : await Filesystem.canonical(requested) const info = !host && cwd ? await fs.stat(cwd).catch(() => undefined) : undefined if (!host && (!cwd || !info?.isDirectory() || !Filesystem.contains(authority.workspace, cwd))) { throw new Error( `Local compute working directory must be inside the session workspace: ${parsed.cwd ?? requested}`, ) } + const id = crypto.randomUUID().slice(0, 12) const prepared = parsed.target.kind === "modal" ? await modal(parsed, cwd!, options.modal) : undefined + const remote = host + ? await SshPlan.prepare({ + id, + command: parsed.command, + resources: parsed.resources, + modules: parsed.modules, + container: parsed.container, + cwd: authority.workspace, + remoteCwd: parsed.cwd, + uploads: parsed.uploads ?? [], + outputs: [...(parsed.artifacts ?? []), ...(parsed.checkpoint ? [parsed.checkpoint] : [])], + host, + }) + : undefined const provider = options.provider ?? ModalAdapter if (prepared && parsed.approval !== prepared.plan.digest) { throw new Error("The Modal run must be approved using its current plan digest") } - if (!host && parsed.target.kind !== "modal") await outputs(cwd!, parsed.artifacts ?? [], parsed.checkpoint) - const id = crypto.randomUUID().slice(0, 12) + if (remote && parsed.approval !== remote.plan.digest) { + throw new Error("The SSH run must be approved using its current plan digest") + } + if (parsed.target.kind !== "modal") await outputs(cwd!, parsed.artifacts ?? [], parsed.checkpoint) + await currentAuthority(authority) const spec = parsed.target.kind === "modal" ? { label: "Modal", scheduler: "none" as const } @@ -1606,6 +2749,18 @@ export namespace ComputeJobs { volume: provider.volume(cwd!, id), } : undefined, + ssh: remote + ? { + protocol: 1, + host, + root: remote.plan.remote_root, + cwd: remote.plan.remote_cwd, + fingerprint: remote.plan.fingerprint, + uploads: remote.plan.uploads, + upload_bytes: remote.plan.upload_bytes, + approval: remote.plan.digest, + } + : undefined, created_at: new Date().toISOString(), resources: parsed.resources, modules: parsed.modules, @@ -1622,92 +2777,192 @@ export namespace ComputeJobs { if (prepared) { const context = await modalContext(options, "Modal credentials were not resolved for dispatch") const key = keyOf(scope.root, draft.id) - const busy = - [...active.values()].filter((runtime) => runtime.root === scope.root && runtime.modal).length + - [...slots.values()].filter((root) => root === scope.root).length - if (busy >= context.concurrency) { - throw new Error(`Modal concurrency limit reached for this project (${busy}/${context.concurrency})`) - } - slots.set(key, scope.root) - return Promise.resolve() - .then(async () => { - const reproducibility = await reproduce(draft, authority) - const base = Job.parse({ ...draft, reproducibility }) - const job = Job.parse({ ...base, provenance: provenance(base) }) - await change(scope.root, (jobs) => { - jobs.push(job) - }) - slots.delete(key) - active.set(key, { - detached: false, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - modal: context, - provider, + const reproducibility = await reproduce(draft, authority) + await currentAuthority(authority) + const base = Job.parse({ ...draft, reproducibility }) + const job = Job.parse({ ...base, provenance: provenance(base) }) + await using admission = await FileLease.acquire(modalAdmissionOf(scope.root)) + await currentAuthority(authority) + await change(scope.root, (jobs) => { + const busy = jobs.filter(reservesModal).length + if (busy >= context.concurrency) { + throw new Error(`Modal concurrency limit reached for this project (${busy}/${context.concurrency})`) + } + jobs.push(job) + }) + + const lease = await FileLease.acquire(modalLeaseOf(scope.root, job.id)) + let handedOff = false + try { + await currentAuthority(authority) + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + modal: context, + provider, + }) + const managed = executeModal(job, prepared.files, scope, context, provider) + .catch((error) => + error instanceof ModalAdapter.HarvestError + ? deferModal(job, scope, error) + : failModal(job, scope, context, error, provider), + ) + .finally(async () => { + await deactivate(key) + await releaseLease(lease) }) - void executeModal(job, prepared.files, scope, context, provider) - .catch((error) => - error instanceof ModalAdapter.HarvestError - ? deferModal(job, scope, error) - : failModal(job, scope, context, error, provider), - ) - .finally(() => active.delete(key)) - return job + handedOff = true + void managed.catch(() => undefined) + return job + } catch (error) { + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const cancelled = move(jobs[index]!, { type: "cancel" }, { completed_at: new Date().toISOString() }) + const closed = move(cancelled, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }).catch(() => undefined) + throw error + } finally { + if (!handedOff) { + await deactivate(key) + await releaseLease(lease) + } + } + } + if (remote && host) { + const reproducibility = await reproduce(draft, authority) + await currentAuthority(authority) + const base = Job.parse({ ...draft, reproducibility }) + const job = Job.parse({ ...base, provenance: provenance(base) }) + await using admission = await FileLease.acquire(sshAdmissionOf(scope.root, host.id)) + await currentAuthority(authority) + await change(scope.root, (jobs) => { + const busy = jobs.filter((item) => reservesSsh(item, host.id)).length + if (busy >= host.concurrency) { + throw new Error(`SSH concurrency limit reached for ${host.label} (${busy}/${host.concurrency})`) + } + jobs.push(job) + }) + await using lease = await FileLease.acquire(sshLeaseOf(scope.root, job.id)) + const key = keyOf(scope.root, job.id) + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + host, + }) + try { + return await startSsh(job, scope, remote.files) + } catch (error) { + const released = await releaseSsh(job, scope, false).then( + () => true, + () => false, + ) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const message = error instanceof Error ? error.message : String(error) + const failed = move( + jobs[index]!, + { type: "finish", outcome: "failed", message }, + { completed_at: new Date().toISOString(), exit_code: null, error: message }, + ) + const closed = released ? move(failed, { type: "close" }) : move(failed, { type: "lose" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) }) - .finally(() => slots.delete(key)) + throw error + } finally { + await deactivate(key) + } } const reproducibility = host ? undefined : await reproduce(draft, authority) + await currentAuthority(authority) const planned = await launch(draft, host, scope, authority).catch(async (error) => { if (!host) await fs.rm(exitOf(scope.root, id), { force: true }) throw error }) - const base = Job.parse({ ...draft, sandbox: planned.sandbox, reproducibility }) - const job = Job.parse({ ...base, provenance: provenance(base) }) - await change(scope.root, (jobs) => { - jobs.push(job) - }).catch(async (error) => { + let job: Job + try { + await currentAuthority(authority) + const base = Job.parse({ ...draft, sandbox: planned.sandbox, reproducibility }) + job = Job.parse({ ...base, provenance: provenance(base) }) + await change(scope.root, (jobs) => { + jobs.push(job) + }) + } catch (error) { + Sandbox.cleanup(planned) if (!host) await fs.rm(exitOf(scope.root, id), { force: true }).catch(() => undefined) throw error - }) + } const key = keyOf(scope.root, job.id) - active.set(key, { - detached: false, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - host, - }) - void execute(job, host, scope, authority, planned) - .catch(async (error) => { - await fs.mkdir(logsOf(scope.root), { recursive: true }) - await fs - .appendFile( - path.join(logsOf(scope.root), `${job.id}.log`), - `${error instanceof Error ? error.message : String(error)}\n`, - ) - .catch(() => {}) - await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === job.id) - if (index < 0 || terminal.has(jobs[index]!.status)) return - const message = error instanceof Error ? error.message : String(error) - const draft = move( - jobs[index]!, - { type: "finish", outcome: "failed", message }, - { - completed_at: new Date().toISOString(), - exit_code: null, - error: message, - }, - ) - const closed = move(draft, { type: "close" }) - jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) - }).catch(() => {}) + const lease = await FileLease.acquire(localLeaseOf(scope.root, job.id)) + let handedOff = false + try { + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + host, }) - .finally(() => active.delete(key)) - return job + const ready = Promise.withResolvers() + const managed = execute(job, host, scope, authority, planned, ready.resolve) + .catch(async (error) => { + // `Sandbox.cleanup` is idempotent. This covers authority/env failures + // that happen before a child is spawned; exit/error listeners own the + // normal running-child path. + Sandbox.cleanup(planned) + await fs.mkdir(logsOf(scope.root), { recursive: true }) + await fs + .appendFile( + path.join(logsOf(scope.root), `${job.id}.log`), + `${error instanceof Error ? error.message : String(error)}\n`, + ) + .catch(() => {}) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const message = error instanceof Error ? error.message : String(error) + const draft = move( + jobs[index]!, + { type: "finish", outcome: "failed", message }, + { + completed_at: new Date().toISOString(), + exit_code: null, + error: message, + }, + ) + const closed = move(draft, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }).catch(() => {}) + }) + .finally(async () => { + try { + await deactivate(key) + } finally { + await releaseLease(lease) + } + }) + handedOff = true + void managed.catch(() => undefined) + await Promise.race([ready.promise, managed.then(() => undefined)]) + return job + } finally { + if (!handedOff) { + try { + await deactivate(key) + } finally { + await releaseLease(lease) + } + } + } } export async function list(options: Options = {}): Promise { @@ -1746,18 +3001,52 @@ export namespace ComputeJobs { export async function cancel(id: string, options: Options = {}): Promise { const scope = await scoped(options) const runtime = active.get(keyOf(scope.root, id)) - const current = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) + let current = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) if (!current) throw new Error(`Compute job ${id} was not found`) + if (current.target.kind === "ssh") return cancelSsh(current, scope) + await using operation = + current.target.kind === "modal" ? await FileLease.acquire(modalOperationOf(scope.root, id)) : undefined + if (current.target.kind === "modal") { + current = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) + if (!current) throw new Error(`Compute job ${id} was not found`) + } const needs = current.target.kind === "modal" && (!terminal.has(current.status) || current.lifecycle?.resource === "unknown") const context = runtime?.modal ?? (needs ? await modalContext(options, "Enable Modal before cancelling this recovered job") : undefined) + const localCancellation = current.target.kind !== "modal" && !terminal.has(current.status) + if (localCancellation) { + // Preserve the live descendant closure before a best-effort process + // signal can kill the leader and reparent a setsid child. + await CredentialProcessLedger.revoke({ id: credentialProcessID(scope.root, id), kind: "compute" }) + } const result = await change(scope.root, (jobs) => { const index = jobs.findIndex((item) => item.id === id) if (index < 0) throw new Error(`Compute job ${id} was not found`) if (terminal.has(jobs[index]!.status)) { const job = jobs[index]! + if (localCancellation && job.status === "failed" && job.exit_code === null) { + const lifecycle = ComputeLifecycle.State.parse({ + ...(job.lifecycle ?? ComputeLifecycle.from(job.status)), + execution: "cancelled", + resource: "closed", + error_kind: undefined, + system_hint: undefined, + }) + const reconciled = Job.parse({ + ...job, + status: "cancelled", + lifecycle, + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + error: undefined, + }) + jobs[index] = Job.parse({ ...reconciled, provenance: provenance(reconciled) }) + return { job: jobs[index]!, changed: true, cleanup: false } + } const cleanup = job.target.kind === "modal" && job.lifecycle?.resource === "unknown" && !!context return { job, changed: false, cleanup } } @@ -1804,13 +3093,18 @@ export namespace ComputeJobs { detached: runtime.detached, exited: () => proc.exitCode !== null, }) - } else if (job.pid) { + } else if (job.pid && (await owns(job.pid, job.process_identity))) { try { if (process.platform === "win32") process.kill(job.pid, "SIGTERM") else process.kill(-job.pid, "SIGTERM") } catch {} + } else if (job.pid) { + await event( + scope.root, + job.id, + "Skipped process termination because the persisted PID no longer matched this job", + ) } - if (runtime) active.delete(keyOf(scope.root, id)) const hostId = job.target.kind === "ssh" ? job.target.host_id : undefined const host = hostId ? options.hosts?.find((item) => item.id === hostId) : undefined if (host && host.scheduler !== "none") { @@ -1827,22 +3121,28 @@ export namespace ComputeJobs { file: spec.argv[0]!, args: spec.argv.slice(1), workspace: job.authority.writable, + readable: job.authority.readable, unreadable: OpenScience.kernelSensitivePaths(), options: job.authority.sandbox, }) - : { file: spec.argv[0]!, args: spec.argv.slice(1) } - const proc = spawn(planned.file, planned.args, { - cwd: job.authority?.workspace, - env: await OpenScience.subprocessEnv(process.env), - windowsHide: true, - stdio: "ignore", - }) - await new Promise((resolve) => { - proc.once("error", () => resolve()) - proc.once("exit", () => resolve()) - }) + : { file: spec.argv[0]!, args: spec.argv.slice(1), temporary: undefined } + let proc: ChildProcess + try { + proc = spawn(planned.file, planned.args, { + cwd: job.authority?.workspace, + env: OpenScience.kernelEnv(process.env), + windowsHide: true, + stdio: "ignore", + }) + await new Promise((resolve) => { + proc.once("error", () => resolve()) + proc.once("exit", () => resolve()) + }) + } finally { + Sandbox.cleanup(planned) + } } - return change(scope.root, (jobs) => { + return await change(scope.root, (jobs) => { const index = jobs.findIndex((item) => item.id === id) if (index < 0) throw new Error(`Compute job ${id} was not found`) const current = jobs[index]! @@ -1863,12 +3163,12 @@ export namespace ComputeJobs { }) jobs[index] = updated return jobs[index]! - }) + }).finally(() => (runtime ? deactivate(keyOf(scope.root, id)) : undefined)) } - async function cancelActive(match: (runtime: Runtime) => boolean): Promise { + async function cancelActive(match: (runtime: Runtime) => boolean, failClosed = false): Promise { const runtimes = [...active.values()].filter(match) - await Promise.allSettled( + const results = await Promise.allSettled( runtimes.map((runtime) => cancel(runtime.id, { root: runtime.root, @@ -1879,15 +3179,160 @@ export namespace ComputeJobs { }), ), ) + if (failClosed) { + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) throw new AggregateError(failures, "Credential-bearing compute jobs could not be revoked") + } return runtimes.length } - export function cancelSession(sessionID: string): Promise { - return cancelActive((runtime) => runtime.authority.sessionID === sessionID) + async function latchLocalCancellation(runtime: Runtime): Promise { + await change(runtime.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === runtime.id) + if (index < 0 || terminal.has(jobs[index]!.status) || jobs[index]!.target.kind !== "local") return + const cancelled = move( + jobs[index]!, + { type: "cancel" }, + { + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = move(cancelled, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + } + + async function revokeActive(scope: { projectID?: string; sessionID?: string }): Promise { + const runtimes = [...active.values()].filter( + (runtime) => + (!scope.projectID || runtime.authority.projectID === scope.projectID) && + (!scope.sessionID || runtime.authority.sessionID === scope.sessionID), + ) + const local = new Map( + runtimes + .filter((runtime) => !runtime.modal && !runtime.host) + .map((runtime) => [credentialProcessID(runtime.root, runtime.id), runtime]), + ) + return CredentialProcessLedger.revoke( + { kind: "compute", ...scope }, + { + onPinned: async (id) => { + const runtime = local.get(id) + if (runtime) await latchLocalCancellation(runtime) + }, + }, + ) + } + + export async function cancelSession(sessionID: string): Promise { + const recovered = await revokeActive({ sessionID }) + const current = await cancelActive((runtime) => runtime.authority.sessionID === sessionID, true) + return Math.max(recovered, current) } - export function cancelProject(projectID: string): Promise { - return cancelActive((runtime) => runtime.authority.projectID === projectID) + export async function cancelProject(projectID: string): Promise { + const recovered = await revokeActive({ projectID }) + const current = await cancelActive((runtime) => runtime.authority.projectID === projectID, true) + return Math.max(recovered, current) + } + + async function credentialRoots(): Promise { + const roots = new Set([...active.values()].filter((runtime) => !runtime.modal).map((runtime) => runtime.root)) + const projects = path.join(Global.Path.data, "compute", "projects") + const entries = await fs.readdir(projects, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return [] + throw error + }) + for (const entry of entries) if (entry.isDirectory()) roots.add(path.join(projects, entry.name)) + return [...roots] + } + + /** Local and SSH job children inherit the credential snapshot that existed + * when they were spawned. Revoke the durable, identity-verified child ledger + * first, then cancel every queued/running non-Modal job on disk—including + * children whose original server process died. Modal has its own isolated + * provider credential lease and is intentionally unaffected. */ + export async function cancelCredentialProcesses(): Promise { + // Snapshot only the in-memory owners that can race their exit finalizer. + // Durable dead-owner jobs have no competing finalizer. Revoke first so a + // corrupt compute history can never prevent credential teardown. + const activeBeforeRevocation = new Set( + [...active.values()].filter((runtime) => !runtime.modal).map((runtime) => keyOf(runtime.root, runtime.id)), + ) + const killed = await CredentialProcessLedger.revoke("compute") + const roots = await credentialRoots() + const cancelled = new Set() + for (const root of roots) { + const stored = await read(root).catch((error) => preserve(root, error)) + for (const job of stored) { + if (job.target.kind === "modal" || !job.pid || !job.process_identity) continue + await CredentialProcessLedger.killExact({ + id: credentialProcessID(root, job.id), + kind: "compute", + pid: job.pid, + identity: job.process_identity, + detached: process.platform !== "win32", + }) + } + await change(root, (jobs) => { + for (let index = 0; index < jobs.length; index++) { + const job = jobs[index]! + if (job.target.kind === "modal") continue + if (terminal.has(job.status)) { + // Revocation deliberately kills the child before publishing the + // cancelled state. The owner finalizer can observe that SIGKILL + // first and transiently record a null-exit failure. If this exact + // process was identity-owned when revocation began, preserve the + // intended cancellation outcome instead of exposing a race-shaped + // failure to the user. + if (job.status === "failed" && job.exit_code === null && activeBeforeRevocation.has(keyOf(root, job.id))) { + const lifecycle = ComputeLifecycle.State.parse({ + ...(job.lifecycle ?? ComputeLifecycle.from(job.status)), + execution: "cancelled", + resource: "closed", + error_kind: undefined, + system_hint: undefined, + }) + const reconciled = Job.parse({ + ...job, + status: "cancelled", + lifecycle, + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + error: undefined, + }) + jobs[index] = Job.parse({ ...reconciled, provenance: provenance(reconciled) }) + cancelled.add(keyOf(root, job.id)) + continue + } + if (job.pid || job.process_identity) { + jobs[index] = Job.parse({ ...job, pid: undefined, process_identity: undefined }) + } + continue + } + const draft = move( + job, + { type: "cancel" }, + { + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = move(draft, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + cancelled.add(keyOf(root, job.id)) + } + }) + } + await Promise.all([...cancelled].map((key) => deactivate(key))) + return Math.max(killed, cancelled.size) } export async function clear(options: Options = {}): Promise { diff --git a/backend/cli/src/compute/modal/plan.ts b/backend/cli/src/compute/modal/plan.ts index d9882e11..e9efce96 100644 --- a/backend/cli/src/compute/modal/plan.ts +++ b/backend/cli/src/compute/modal/plan.ts @@ -95,14 +95,14 @@ export namespace ModalPlan { return new Set(files.filter((file) => matcher.ignores(file))) } - async function inputs(root: string, patterns: string[]) { + export async function files(root: string, patterns: string[], label = "Modal") { const project = await Filesystem.canonical(root) - if (!project) throw new Error(`Modal project directory is unavailable: ${root}`) + if (!project) throw new Error(`${label} project directory is unavailable: ${root}`) const files = new Map() const found = new Set() for (const pattern of patterns) { if (path.isAbsolute(pattern) || pattern.split(/[\\/]/).includes("..")) { - throw new Error(`Modal upload pattern must stay inside the project: ${pattern}`) + throw new Error(`${label} upload pattern must stay inside the project: ${pattern}`) } const scan = new Bun.Glob(pattern).scan({ cwd: project, dot: true, onlyFiles: true, followSymlinks: true }) for await (const file of scan) found.add(posix(file)) @@ -110,16 +110,16 @@ export namespace ModalPlan { const excludes = await ignored(project, [...found]) for (const relative of found) { if (excludes.has(relative)) continue - if (forbidden(relative)) throw new Error(`Modal upload policy denied: ${relative}`) + if (forbidden(relative)) throw new Error(`${label} upload policy denied: ${relative}`) const canonical = await Filesystem.canonical(path.resolve(project, relative)) if (!canonical || !Filesystem.contains(project, canonical)) { - throw new Error(`Modal upload escaped the project: ${relative}`) + throw new Error(`${label} upload escaped the project: ${relative}`) } const resolved = posix(path.relative(project, canonical)) const canonicalIgnored = resolved === relative ? excludes.has(resolved) : (await ignored(project, [resolved])).has(resolved) if (canonicalIgnored) continue - if (forbidden(resolved)) throw new Error(`Modal upload policy denied: ${relative}`) + if (forbidden(resolved)) throw new Error(`${label} upload policy denied: ${relative}`) const info = await fs.stat(canonical) files.set(canonical, { path: resolved, @@ -130,12 +130,12 @@ export namespace ModalPlan { } const result = [...files.values()].toSorted((a, b) => a.path.localeCompare(b.path)) const bytes = result.reduce((sum, file) => sum + file.size, 0) - if (bytes > 104_857_600) throw new Error("Modal uploads exceed the 100 MiB approval limit") + if (bytes > 104_857_600) throw new Error(`${label} uploads exceed the 100 MiB approval limit`) return { files: result, bytes } } export async function prepare(input: Input): Promise { - const upload = await inputs(input.cwd, input.uploads) + const upload = await files(input.cwd, input.uploads) const value = { provider: "modal" as const, app: input.context.app, diff --git a/backend/cli/src/compute/modal/volume.ts b/backend/cli/src/compute/modal/volume.ts index cabd4ab1..4dbb862b 100644 --- a/backend/cli/src/compute/modal/volume.ts +++ b/backend/cli/src/compute/modal/volume.ts @@ -1,7 +1,15 @@ import fs from "fs/promises" import path from "path" +import { spawn, type ChildProcess } from "node:child_process" import driver from "./volume.py" with { type: "file" } import { Global } from "../../global" +import { DataRootBarrier } from "../../global/data-root-barrier" +import { CredentialLifecycle } from "../../credentials/lifecycle" +import { CredentialProcessLedger } from "../../credentials/process-ledger" +import { ProcessIdentity } from "../../process/process-identity" +import { DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX } from "../../process/darwin-responsibility-launcher" +import { WindowsJobLauncher } from "../../process/windows-job-launcher" +import { Shell } from "../../shell/shell" export namespace ModalVolume { export const VERSION = "1.1.4" @@ -52,7 +60,9 @@ export namespace ModalVolume { const LIST_TIMEOUT = 60_000 const DOWNLOAD_TIMEOUT = 10 * 60_000 - const GRACE = 200 + const PROBE_TIMEOUT = 15_000 + const MAX_STDOUT = 8 * 1024 * 1024 + const MAX_STDERR = 1024 * 1024 const text = new TextDecoder() const clean = (value: string) => value.replaceAll("\\", "/").replace(/^\/+/, "") const safe = (value: string) => { @@ -93,21 +103,18 @@ export namespace ModalVolume { const file = await driverPath() const python = context.python ?? Bun.which("python3") ?? Bun.which("python") if (python) { - const probe = Bun.spawn( + const probe = await execute( [ python, "-I", "-c", `import modal; assert modal.__version__ == '${VERSION}'; assert hasattr(modal.Volume, 'read_file')`, ], - { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - env: environment(context.env ?? process.env), - }, + environment({ ...process.env, ...context.env }), + PROBE_TIMEOUT, + "SDK probe", ) - if ((await probe.exited) === 0) return [python, "-I", file] + if (probe.code === 0) return [python, "-I", file] } const uv = context.uv ?? Bun.which("uv") if (uv) { @@ -116,64 +123,208 @@ export namespace ModalVolume { throw new Error("Modal Volume access requires uv or a Python installation that can import the Modal SDK") } - function environment(source: Record) { - const env = { ...source } - for (const name of ["PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP", "PYTHONINSPECT", "PYTHONUSERBASE"]) { - delete env[name] + const RUNTIME_ENV = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CACHE_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "USERPROFILE", + ]) + + /** Minimal runtime environment for the trusted bridge. Provider/cloud keys, + * OpenScience control-plane state, dynamic-loader injection, and Python + * startup injection are deliberately absent. */ + export function environment(source: Record = process.env): Record { + const env: Record = {} + for (const [name, value] of Object.entries(source)) { + if (!value) continue + const key = process.platform === "win32" ? name.toUpperCase() : name + if (RUNTIME_ENV.has(key) || key.startsWith("LC_")) env[name] = value + } + return { + ...env, + PYTHONNOUSERSITE: "1", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_TERMINAL_PROMPT: "0", } - env.PYTHONNOUSERSITE = "1" - return env } - function kill(pid: number) { - if (process.platform === "win32") { - Bun.spawn(["taskkill", "/pid", String(pid), "/f", "/t"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", + function output(stream: NodeJS.ReadableStream, limit: number, label: string): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let settled = false + const fail = (error: unknown) => { + if (settled) return + settled = true + reject(error) + } + stream.on("data", (value: Buffer | string) => { + if (settled) return + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + size += chunk.length + if (size > limit) { + fail(new Error(`Modal Volume ${label} exceeded ${limit} bytes`)) + return + } + chunks.push(chunk) + }) + stream.once("error", fail) + stream.once("end", () => { + if (settled) return + settled = true + resolve(Buffer.concat(chunks, size)) }) - return + }) + } + + async function cleanupGate(release?: string) { + if (!release) return + await Promise.all([ + fs.rm(release, { force: true }).catch(() => undefined), + fs.rm(`${release}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, { force: true }).catch(() => undefined), + ]) + } + + async function stop(id: string, child: ChildProcess, detached: boolean, identity?: string) { + const failures: unknown[] = [] + await CredentialProcessLedger.revoke({ id, kind: "modal-volume" }).catch((error) => failures.push(error)) + const stillOwned = child.pid && identity ? await CredentialProcessLedger.owns(child.pid, identity) : true + if (stillOwned && child.exitCode === null && child.signalCode === null) { + await Shell.killTree(child, { + detached, + exited: () => child.exitCode !== null || child.signalCode !== null, + }).catch((error) => failures.push(error)) } - try { - process.kill(-pid, "SIGTERM") - } catch { - return + if (failures.length) throw new AggregateError(failures, "Modal Volume bridge could not be stopped") + } + + async function complete(id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await CredentialProcessLedger.complete(id)) return + await Bun.sleep(20) } - setTimeout(() => { + await CredentialProcessLedger.revoke({ id, kind: "modal-volume" }) + } + + async function execute(argv: string[], env: Record, timeout: number, action: string, stdin?: Buffer) { + await using operation = await DataRootBarrier.enter(Global.Path.data) + const launched = await CredentialLifecycle.admit(async () => { + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + throw new Error("Could not capture the Linux server identity for Modal Volume launch") + } + const wrapped = WindowsJobLauncher.wrap({ + file: argv[0]!, + args: argv.slice(1), + linuxOwner, + }) + const detached = process.platform !== "win32" + const child = spawn(wrapped.file, wrapped.args, { + env, + detached, + windowsHide: true, + stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"], + }) + const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + const stdout = output(child.stdout!, MAX_STDOUT, `${action} stdout`) + const stderr = output(child.stderr!, MAX_STDERR, `${action} stderr`) + // Registration can fail before the main result race is installed. Keep + // these promises observed during that window without changing their + // eventual rejected state for the caller. + void completion.catch(() => undefined) + void stdout.catch(() => undefined) + void stderr.catch(() => undefined) + const id = `modal-volume-${crypto.randomUUID()}` + let identity: string | undefined try { - process.kill(-pid, "SIGKILL") - } catch {} - }, GRACE) + if (!child.pid) throw new Error("Modal Volume bridge started without a process id") + identity = await CredentialProcessLedger.identity(child.pid) + if (!identity) throw new Error(`Could not establish a safe identity for Modal Volume ${action}`) + const registered = await CredentialProcessLedger.register({ + id, + kind: "modal-volume", + pid: child.pid, + detached, + identity, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error(`Modal Volume ${action} exited before durable ownership was established`) + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid) + } + if (stdin) child.stdin!.end(stdin) + return { child, completion, stdout, stderr, id, detached, identity, release: wrapped.release } + } catch (error) { + await stop(id, child, detached, identity).catch(() => undefined) + await cleanupGate(wrapped.release) + throw error + } + }) + + let timer: ReturnType | undefined + const expired = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Modal Volume ${action} timed out after ${timeout}ms`)), timeout) + }) + const result = Promise.all([launched.stdout, launched.stderr, launched.completion] as const) + let normal = false + try { + const [stdout, stderr, status] = await Promise.race([result, expired]) + normal = true + return { stdout, stderr, code: status.code, signal: status.signal } + } catch (error) { + result.catch(() => undefined) + await stop(launched.id, launched.child, launched.detached, launched.identity) + throw error + } finally { + if (timer) clearTimeout(timer) + if (normal) await complete(launched.id) + await cleanupGate(launched.release) + } } async function invoke(request: Request, context: Context, timeout: number) { - const env = environment(context.env ?? process.env) + const env = environment({ ...process.env, ...context.env }) env.MODAL_TOKEN_ID = context.tokenId env.MODAL_TOKEN_SECRET = context.tokenSecret - const proc = Bun.spawn(await command(context), { - stdin: Buffer.from(JSON.stringify(request)), - stdout: "pipe", - stderr: "pipe", + const { stdout, stderr, code, signal } = await execute( + await command(context), env, - detached: true, - }) - const drained = Promise.all([ - new Response(proc.stdout).arrayBuffer(), - new Response(proc.stderr).arrayBuffer(), - proc.exited, - ]) - const timer = Bun.sleep(timeout).then(() => undefined) - const result = await Promise.race([drained, timer]) - if (!result) { - kill(proc.pid) - drained.catch(() => undefined) - throw new Error(`Modal Volume ${request.action} timed out after ${timeout}ms`) - } - const [stdout, stderr, code] = result - if (proc.signalCode) throw new Error(`Modal Volume ${request.action} was killed by ${proc.signalCode}`) + timeout, + request.action, + Buffer.from(JSON.stringify(request)), + ) + if (signal) throw new Error(`Modal Volume ${request.action} was killed by ${signal}`) if (code !== 0) { const detail = stderr.byteLength ? stderr : stdout - throw new Error(`Modal Volume ${request.action} failed (exit ${code}): ${text.decode(detail).trim()}`) + const message = [context.tokenId, context.tokenSecret].reduce( + (value, secret) => (secret ? value.replaceAll(secret, "[REDACTED]") : value), + text.decode(detail).trim(), + ) + throw new Error(`Modal Volume ${request.action} failed (exit ${code}): ${message}`) } try { return JSON.parse(text.decode(stdout)) as unknown @@ -305,3 +456,9 @@ export namespace ModalVolume { ) } } + +// A credential rotation in this or another server must revoke any helper that +// inherited the prior Modal token pair before the new revision is acknowledged. +CredentialLifecycle.onRevoke(async () => { + await CredentialProcessLedger.revoke("modal-volume") +}) diff --git a/backend/cli/src/compute/ssh/adapter.ts b/backend/cli/src/compute/ssh/adapter.ts new file mode 100644 index 00000000..2c6e7bb5 --- /dev/null +++ b/backend/cli/src/compute/ssh/adapter.ts @@ -0,0 +1,1272 @@ +import { spawn } from "node:child_process" +import crypto from "node:crypto" +import { createReadStream } from "node:fs" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import type { ModalAdapter } from "../modal/adapter" + +export namespace SshAdapter { + export type Scheduler = "none" | "slurm" | "pbs" + + export type Host = { + id: string + label: string + host: string + user?: string + port?: number + scheduler: Scheduler + workdir?: string + fingerprint?: string + host_key?: string + concurrency?: number + } + + export type Upload = Pick + + export type Spec = { + id: string + owner: string + root: string + cwd: string + command: string + scheduler: Scheduler + resources?: { + cpus?: number + gpus?: number + memory_gb?: number + time_minutes?: number + partition?: string + } + modules?: string[] + container?: string + outputs: string[] + uploads: Upload[] + } + + export type Result = { + state: "queued" | "running" | "done" | "cancelled" | "unknown" + code?: number + detail?: string + } + + export type Manifest = { + files: { path: string; size: number; sha256: string }[] + } + + const SUPERVISOR = String.raw`#!/usr/bin/env python3 +import ctypes +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import sys +import time + +root = pathlib.Path(sys.argv[1]).resolve() +script = pathlib.Path(sys.argv[2]).resolve() +unit = sys.argv[3] if len(sys.argv) > 3 else "" +cancelled = False +forced = False +primary = 0 +code = None + +def atomic(name, value): + target = root / name + temp = root / (name + ".tmp-" + str(os.getpid())) + temp.write_text(str(value) + "\n", encoding="utf-8") + os.replace(temp, target) + +def identity(pid): + stat = pathlib.Path("/proc") / str(pid) / "stat" + if stat.is_file(): + text = stat.read_text(encoding="utf-8") + fields = text[text.rfind(")") + 2:].split() + return "proc:" + fields[19] + result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + return "ps:" + result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else "" + +darwin_library = None +darwin_owner = 0 +darwin_marker = "--openscience-responsibility-root" + +def darwin_symbols(): + global darwin_library + if sys.platform != "darwin": + return None + if darwin_library is not None: + return darwin_library + try: + library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + library.responsibility_get_pid_responsible_for_pid.argtypes = [ctypes.c_int] + library.responsibility_get_pid_responsible_for_pid.restype = ctypes.c_int + library.responsibility_get_uniqueid_responsible_for_pid.argtypes = [ctypes.c_int] + library.responsibility_get_uniqueid_responsible_for_pid.restype = ctypes.c_uint64 + library.posix_spawnattr_init.argtypes = [ctypes.POINTER(ctypes.c_void_p)] + library.posix_spawnattr_init.restype = ctypes.c_int + library.posix_spawnattr_setflags.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_short] + library.posix_spawnattr_setflags.restype = ctypes.c_int + library.responsibility_spawnattrs_setdisclaim.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_bool] + library.responsibility_spawnattrs_setdisclaim.restype = ctypes.c_int + library.posix_spawnattr_destroy.argtypes = [ctypes.POINTER(ctypes.c_void_p)] + library.posix_spawnattr_destroy.restype = ctypes.c_int + library.posix_spawn.argtypes = [ + ctypes.POINTER(ctypes.c_int), + ctypes.c_char_p, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_char_p), + ] + library.posix_spawn.restype = ctypes.c_int + darwin_library = library + return library + except (AttributeError, OSError): + return None + +def darwin_unique(pid): + library = darwin_symbols() + if library is None: + return 0 + try: + return int(library.responsibility_get_uniqueid_responsible_for_pid(int(pid))) + except (OverflowError, ValueError): + return 0 + +def darwin_exec_root(library): + attributes = ctypes.c_void_p() + initialized = False + def check(action, code): + if code != 0: + raise RuntimeError(action + " failed (errno " + str(code) + ")") + try: + check("posix_spawnattr_init", library.posix_spawnattr_init(ctypes.byref(attributes))) + initialized = True + check("responsibility_spawnattrs_setdisclaim", library.responsibility_spawnattrs_setdisclaim(ctypes.byref(attributes), True)) + check("posix_spawnattr_setflags", library.posix_spawnattr_setflags(ctypes.byref(attributes), 0x0040)) + executable = os.fsencode(sys.executable) + arguments = [executable] + [os.fsencode(value) for value in sys.argv] + [os.fsencode(darwin_marker)] + environment = [os.fsencode(key + "=" + value) for key, value in os.environ.items()] + argv = (ctypes.c_char_p * (len(arguments) + 1))(*arguments, None) + envp = (ctypes.c_char_p * (len(environment) + 1))(*environment, None) + spawned = ctypes.c_int() + check("posix_spawn(POSIX_SPAWN_SETEXEC)", library.posix_spawn(ctypes.byref(spawned), executable, None, ctypes.byref(attributes), argv, envp)) + raise RuntimeError("posix_spawn(POSIX_SPAWN_SETEXEC) returned after successful process replacement") + finally: + if initialized: + library.posix_spawnattr_destroy(ctypes.byref(attributes)) + +def darwin_responsibility(): + global darwin_owner + library = darwin_symbols() + if library is None: + return False + if not sys.argv or sys.argv[-1] != darwin_marker: + darwin_exec_root(library) + responsible = int(library.responsibility_get_pid_responsible_for_pid(os.getpid())) + owner = darwin_unique(os.getpid()) + if responsible != os.getpid() or owner <= 0: + return False + darwin_owner = owner + return True + +def darwin_members(): + if not darwin_owner: + return [] + result = subprocess.run(["ps", "-axo", "pid="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if result.returncode != 0: + return [] + found = [] + for value in result.stdout.split(): + if value.isdigit(): + pid = int(value) + if pid != os.getpid() and darwin_unique(pid) == darwin_owner: + found.append(pid) + return found + +def darwin_owns(pid): + return not darwin_owner or darwin_unique(pid) == darwin_owner + +def subreaper(): + if not sys.platform.startswith("linux"): + return False + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(36, 1, 0, 0, 0) != 0: + return False + return True + +def children(pid): + table = {} + proc = pathlib.Path("/proc") + if not proc.is_dir(): + return [] + for item in proc.iterdir(): + if not item.name.isdigit(): + continue + try: + text = (item / "stat").read_text(encoding="utf-8") + parent = int(text[text.rfind(")") + 2:].split()[1]) + table.setdefault(parent, []).append(int(item.name)) + except (FileNotFoundError, PermissionError, ProcessLookupError, ValueError, IndexError): + continue + found = [] + pending = list(table.get(pid, [])) + while pending: + child = pending.pop() + if child in found: + continue + found.append(child) + pending.extend(table.get(child, [])) + return found + +def tagged(): + token = "OPENSCIENCE_JOB_ID=" + hashlib.sha256(str(root).encode()).hexdigest() + result = subprocess.run(["ps", "eww", "-axo", "pid=,command="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if result.returncode != 0: + return [] + found = [] + for line in result.stdout.splitlines(): + fields = line.strip().split(None, 1) + if len(fields) == 2 and fields[0].isdigit() and token in fields[1]: + found.append(int(fields[0])) + return found + +def cgroup_members(): + if not unit or not pathlib.Path("/sys/fs/cgroup").is_dir(): + return [] + result = subprocess.run(["systemctl", "--user", "show", unit, "--property=ControlGroup", "--value"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + group = result.stdout.strip() + if result.returncode != 0 or not group.startswith("/"): + return [] + folder = pathlib.Path("/sys/fs/cgroup") / group.lstrip("/") + found = [] + try: + lists = list(folder.rglob("cgroup.procs")) + except (FileNotFoundError, PermissionError): + return [] + for item in lists: + try: + found.extend(int(value) for value in item.read_text(encoding="utf-8").split()) + except (FileNotFoundError, PermissionError, ValueError): + continue + return sorted(set(found)) + +def members(): + return [pid for pid in cgroup_members() if pid != os.getpid()] + +def scoped(): + return os.getpid() in cgroup_members() + +def owned(): + return sorted(set(children(os.getpid()) + members() + tagged() + darwin_members())) + +def send(sig): + targets = owned() + group_owned = False + for pid in targets: + try: + if os.getpgid(pid) == primary and darwin_owns(pid): + group_owned = True + break + except (ProcessLookupError, PermissionError): + pass + if primary and group_owned: + try: + os.killpg(primary, sig) + except ProcessLookupError: + pass + except PermissionError: + pass + for pid in reversed(targets): + if pid == os.getpid(): + continue + if not darwin_owns(pid): + continue + try: + os.kill(pid, sig) + except ProcessLookupError: + pass + except PermissionError: + pass + +def reap(): + global code + while True: + try: + pid, status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return False + if pid == 0: + return True + if pid == primary: + code = os.waitstatus_to_exitcode(status) + +def request(_signal, _frame): + global cancelled + cancelled = True + +def force(_signal, _frame): + global cancelled, forced + cancelled = True + forced = True + +signal.signal(signal.SIGTERM, request) +signal.signal(signal.SIGINT, request) +if hasattr(signal, "SIGUSR1"): + signal.signal(signal.SIGUSR1, force) + +responsible = darwin_responsibility() +adopts = subreaper() +scope = scoped() +containment = "darwin-responsibility" if responsible else "linux-subreaper" if adopts else "systemd-scope" if scope else "" +if not containment: + atomic("containment-error", "Direct SSH dispatch requires a verified Linux subreaper, systemd scope, or macOS responsibility root") + raise SystemExit(125) +atomic("runtime.json", json.dumps({"pid": os.getpid(), "identity": identity(os.getpid()), "unit": unit, "subreaper": adopts, "responsibility": darwin_owner, "containment": containment}, separators=(",", ":"))) +environment = dict(os.environ) +environment["OPENSCIENCE_JOB_ID"] = hashlib.sha256(str(root).encode()).hexdigest() +process = subprocess.Popen(["bash", str(script)], cwd=str(root / "work"), stdin=subprocess.DEVNULL, env=environment, start_new_session=True, close_fds=True) +primary = process.pid +started = None + +while True: + live = reap() + extra = owned() + if cancelled: + if started is None: + started = time.monotonic() + send(signal.SIGKILL if forced or time.monotonic() - started >= 2 else signal.SIGTERM) + if not live and not extra: + atomic("cancelled", "1") + raise SystemExit(0) + time.sleep(0.02) + continue + if not live and not extra: + atomic("exit", code if code is not None else 1) + raise SystemExit(code if code is not None else 1) + time.sleep(0.02) +` + + const BROKER = String.raw`import hashlib, json, os, pathlib, secrets, stat, sys + +root = pathlib.Path(sys.argv[1]) +staging = pathlib.Path(sys.argv[2]) +manifest = json.load(sys.stdin) +directory = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + +def descend(base, parts, create): + current = os.dup(base) + try: + for name in parts: + if not name or name in (".", "..") or "/" in name or "\\" in name: + raise RuntimeError("Unsafe SSH output path component") + try: + child = os.open(name, directory, dir_fd=current) + except FileNotFoundError: + if not create: + raise + try: + os.mkdir(name, 0o700, dir_fd=current) + except FileExistsError: + pass + child = os.open(name, directory, dir_fd=current) + os.close(current) + current = child + return current + except BaseException: + os.close(current) + raise + +rootfd = os.open(root, directory) +stagefd = os.open(staging, directory) +try: + for item in manifest["files"]: + parts = pathlib.PurePosixPath(item["path"]).parts + if not parts: + raise RuntimeError("Unsafe empty SSH output path") + temporary = "." + parts[-1] + "." + secrets.token_hex(16) + ".openscience.tmp" + source_parent = -1 + target_parent = -1 + source = -1 + target = -1 + try: + try: + source_parent = descend(stagefd, parts[:-1], False) + source = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=source_parent) + except OSError: + raise RuntimeError("SSH output staging changed during delivery: " + item["path"]) from None + source_stat = os.fstat(source) + if not stat.S_ISREG(source_stat.st_mode): + raise RuntimeError("SSH output staging member is not a regular file: " + item["path"]) + try: + target_parent = descend(rootfd, parts[:-1], True) + target = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=target_parent) + digest = hashlib.sha256() + size = 0 + while True: + chunk = os.read(source, 1024 * 1024) + if not chunk: + break + digest.update(chunk) + size += len(chunk) + view = memoryview(chunk) + while view: + written = os.write(target, view) + view = view[written:] + os.fsync(target) + if size != item["size"] or digest.hexdigest() != item["sha256"]: + raise RuntimeError("SSH output copy failed integrity verification: " + item["path"]) + os.close(target) + target = -1 + try: + os.link(temporary, parts[-1], src_dir_fd=target_parent, dst_dir_fd=target_parent, follow_symlinks=False) + except FileExistsError: + existing = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=target_parent) + try: + existing_digest = hashlib.sha256() + existing_size = 0 + while True: + chunk = os.read(existing, 1024 * 1024) + if not chunk: + break + existing_digest.update(chunk) + existing_size += len(chunk) + if existing_size != item["size"] or existing_digest.hexdigest() != item["sha256"]: + raise RuntimeError("Refusing to replace an existing workspace file with SSH output: " + item["path"]) + finally: + os.close(existing) + os.unlink(temporary, dir_fd=target_parent) + os.fsync(target_parent) + except OSError: + raise RuntimeError("SSH output destination changed during delivery: " + item["path"]) from None + finally: + if source >= 0: + os.close(source) + if target >= 0: + os.close(target) + if target_parent >= 0: + try: + os.unlink(temporary, dir_fd=target_parent) + except FileNotFoundError: + pass + if source_parent >= 0: + os.close(source_parent) + if target_parent >= 0: + os.close(target_parent) +finally: + os.close(stagefd) + os.close(rootfd) +` + + const CONTROL = String.raw`#!/usr/bin/env python3 +import glob +import hashlib +import json +import os +import pathlib +import shlex +import shutil +import signal +import subprocess +import sys +import tarfile +import tempfile +import time + +root = pathlib.Path(__file__).resolve().parent + +def atomic(name, value): + target = root / name + temp = root / (name + ".tmp-" + str(os.getpid())) + temp.write_text(str(value) + "\n", encoding="utf-8") + os.replace(temp, target) + +def own(token): + saved = (root / "owner").read_text(encoding="utf-8").strip() + if not token or not hashlib.sha256(token.encode()).hexdigest() == saved: + raise RuntimeError("OpenScience SSH job ownership mismatch") + +def spec(): + return json.loads((root / "spec.json").read_text(encoding="utf-8")) + +def remote_id(): + return (root / "remote-id").read_text(encoding="utf-8").strip() + +def response(value): + sys.stdout.write(json.dumps(value, separators=(",", ":")) + "\n") + +def identity(pid): + stat = pathlib.Path("/proc") / str(pid) / "stat" + if stat.is_file(): + try: + text = stat.read_text(encoding="utf-8") + return "proc:" + text[text.rfind(")") + 2:].split()[19] + except (FileNotFoundError, ProcessLookupError, IndexError): + return "" + result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + return "ps:" + result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else "" + +def runtime(): + try: + value = json.loads((root / "runtime.json").read_text(encoding="utf-8")) + if not isinstance(value.get("pid"), int) or not isinstance(value.get("identity"), str): + raise RuntimeError("OpenScience SSH runtime identity is invalid") + return value + except (FileNotFoundError, json.JSONDecodeError): + return None + +def alive(value): + return bool(value and value.get("identity") and identity(value["pid"]) == value["identity"]) + +def scope_ready(): + if not shutil.which("systemd-run") or not shutil.which("systemctl"): + return False + name = "openscience-probe-" + str(os.getpid()) + "-" + str(time.time_ns()) + ".scope" + result = subprocess.run(["systemd-run", "--user", "--scope", "--quiet", "--unit=" + name, "true"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return result.returncode == 0 + +def scope_empty(value): + unit = value.get("unit") if value else "" + if not unit: + return True + result = subprocess.run(["systemctl", "--user", "is-active", unit], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + return result.stdout.strip() not in ("active", "activating", "deactivating", "reloading") + +def workload(value): + command = value["command"] + if value.get("container"): + command = "runtime=$(command -v apptainer || command -v singularity) || { echo 'OpenScience requires Apptainer or Singularity for this runtime image' >&2; exit 127; }; \"$runtime\" exec " + shlex.quote(value["container"]) + " bash -lc " + shlex.quote(command) + modules = value.get("modules") or [] + if modules: + command = "module load " + " ".join(shlex.quote(item) for item in modules) + " && " + command + return command + +def run_script(value): + work = (root / "work").resolve() + cwd = (work / value["cwd"]).resolve() + if work != cwd and work not in cwd.parents: + raise RuntimeError("OpenScience SSH job cwd escaped its staged workspace") + cwd.mkdir(parents=True, exist_ok=True) + command = workload(value) + script = root / "run.sh" + script.write_text("\n".join([ + "#!/usr/bin/env bash", + "set +e", + "cd " + shlex.quote(str(cwd)), + "exec bash -lc " + shlex.quote(command), + "", + ]), encoding="utf-8") + script.chmod(0o700) + return script + +def flags(value): + resources = value.get("resources") or {} + if value["scheduler"] == "slurm": + result = [] + if resources.get("cpus"): + result.append("--cpus-per-task=" + str(resources["cpus"])) + if resources.get("gpus"): + result.append("--gres=gpu:" + str(resources["gpus"])) + if resources.get("memory_gb"): + result.append("--mem=" + str(resources["memory_gb"]) + "G") + if resources.get("time_minutes"): + minutes = int(resources["time_minutes"]) + result.append("--time=%02d:%02d:00" % (minutes // 60, minutes % 60)) + if resources.get("partition"): + result.append("--partition=" + resources["partition"]) + return result + if value["scheduler"] == "pbs": + selected = ["select=1"] + if resources.get("cpus"): + selected.append("ncpus=" + str(resources["cpus"])) + if resources.get("gpus"): + selected.append("ngpus=" + str(resources["gpus"])) + if resources.get("memory_gb"): + selected.append("mem=" + str(resources["memory_gb"]) + "gb") + result = [] if len(selected) == 1 else ["-l", ":".join(selected)] + if resources.get("time_minutes"): + minutes = int(resources["time_minutes"]) + result += ["-l", "walltime=%02d:%02d:00" % (minutes // 60, minutes % 60)] + if resources.get("partition"): + result += ["-q", resources["partition"]] + return result + return [] + +def recover_scheduler(value, name): + if value["scheduler"] == "slurm": + live = subprocess.run(["squeue", "-h", "--name=" + name, "-o", "%A|%j"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + for line in live.stdout.splitlines(): + fields = line.strip().split("|", 1) + if len(fields) == 2 and fields[1] == name and fields[0]: + return "slurm:" + fields[0] + history = subprocess.run(["sacct", "-n", "-X", "--name=" + name, "--format=JobIDRaw,JobName", "-P"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + for line in history.stdout.splitlines(): + fields = line.strip().split("|", 1) + if len(fields) == 2 and fields[1] == name and fields[0]: + return "slurm:" + fields[0] + return "" + query = subprocess.run(["qstat", "-f"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + identifier = "" + matched = False + for line in query.stdout.splitlines() + [""]: + if line.startswith("Job Id:"): + if matched and identifier: + return "pbs:" + identifier + identifier = line.split(":", 1)[1].strip() + matched = False + continue + if "Job_Name" in line and "=" in line: + matched = line.split("=", 1)[1].strip() == name + if not line.strip() and matched and identifier: + return "pbs:" + identifier + return "" + +def submit(token): + own(token) + if (root / "remote-id").exists(): + response({"remote_id": remote_id(), "reattached": True}) + return + value = spec() + script = run_script(value) + log = root / "log" + log.touch(mode=0o600, exist_ok=True) + raw_name = ("os-" + value["id"])[0:63] + name = raw_name.replace("-", "_")[0:15] if value["scheduler"] == "pbs" else raw_name + intent = root / "intent.json" + if intent.exists(): + saved_intent = json.loads(intent.read_text(encoding="utf-8")) + if saved_intent.get("scheduler") != value["scheduler"] or saved_intent.get("name") != name: + raise RuntimeError("SSH submission intent does not match the staged scheduler contract") + if value["scheduler"] == "none": + saved = runtime() + if saved and (alive(saved) or (root / "exit").exists() or (root / "cancelled").exists()): + identifier = "pid:" + str(saved["pid"]) + atomic("remote-id", identifier) + response({"remote_id": identifier, "reattached": True}) + return + else: + recovered = recover_scheduler(value, name) + if recovered: + atomic("remote-id", recovered) + response({"remote_id": recovered, "reattached": True}) + return + raise RuntimeError("SSH submission intent exists but the accepted resource is not yet discoverable; retry without creating a duplicate") + atomic("intent.json", json.dumps({"scheduler": value["scheduler"], "name": name, "created_at": time.time_ns()}, separators=(",", ":"))) + if value["scheduler"] == "slurm": + command = ["sbatch", "--parsable", "--job-name=" + name, "--output=" + str(log), "--error=" + str(log)] + flags(value) + [str(script)] + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + intent.unlink(missing_ok=True) + raise RuntimeError("sbatch failed: " + result.stderr.strip()) + identifier = result.stdout.strip().splitlines()[-1].split(";", 1)[0] + if not identifier: + raise RuntimeError("sbatch returned no job id") + identifier = "slurm:" + identifier + elif value["scheduler"] == "pbs": + command = ["qsub", "-N", name, "-j", "oe", "-o", str(log)] + flags(value) + [str(script)] + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + intent.unlink(missing_ok=True) + raise RuntimeError("qsub failed: " + result.stderr.strip()) + identifier = result.stdout.strip().splitlines()[-1].split()[0] + if not identifier: + raise RuntimeError("qsub returned no job id") + identifier = "pbs:" + identifier + else: + for marker in ("runtime.json", "exit", "cancelled", "containment-error"): + try: + (root / marker).unlink() + except FileNotFoundError: + pass + scoped = scope_ready() + unit = "openscience-" + hashlib.sha256(value["id"].encode()).hexdigest()[0:20] + ".scope" if scoped else "" + supervise = [sys.executable, str(root / "supervisor.py"), str(root), str(script), unit] + command = ["systemd-run", "--user", "--scope", "--quiet", "--unit=" + unit] + supervise if scoped else supervise + output = open(log, "ab", buffering=0) + launcher = subprocess.Popen(command, cwd=str(root / "work"), stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True) + output.close() + deadline = time.monotonic() + 10 + value = runtime() + while (not value or not alive(value)) and time.monotonic() < deadline: + if launcher.poll() is not None: + break + time.sleep(0.02) + value = runtime() + if not value or not alive(value): + intent.unlink(missing_ok=True) + detail = (root / "containment-error").read_text(encoding="utf-8").strip() if (root / "containment-error").exists() else "" + raise RuntimeError(detail or "Direct SSH ownership supervisor did not become ready") + identifier = "pid:" + str(value["pid"]) + atomic("remote-id", identifier) + response({"remote_id": identifier, "reattached": False}) + +def slurm_result(raw_state, raw_exit): + state = raw_state.upper().strip().split()[0].rstrip("+") + exit_fields = raw_exit.split(":", 1) + status = int(exit_fields[0]) if exit_fields[0].lstrip("-").isdigit() else 1 + termination = int(exit_fields[1]) if len(exit_fields) > 1 and exit_fields[1].isdigit() else 0 + if state == "CANCELLED": + return {"state": "cancelled", "detail": raw_state} + if state in ("PENDING", "CONFIGURING"): + return {"state": "queued", "detail": raw_state} + if state in ("RUNNING", "COMPLETING", "RESIZING", "SUSPENDED"): + return {"state": "running", "detail": raw_state} + code = 0 if state == "COMPLETED" and status == 0 and termination == 0 else status or (128 + termination if termination else 1) + return {"state": "done", "code": code, "detail": raw_state} + +def scheduler_status(identifier, value): + raw = identifier.split(":", 1)[1] + if identifier.startswith("slurm:"): + live = subprocess.run(["squeue", "-h", "-j", raw, "-o", "%T"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + state = live.stdout.strip().splitlines() + if state: + name = state[0].upper() + return {"state": "queued" if name in ("PENDING", "CONFIGURING") else "running", "detail": name} + history = subprocess.run(["sacct", "-n", "-X", "-P", "-j", raw, "--format=State,ExitCode"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + rows = [line for line in history.stdout.splitlines() if line.strip()] + if rows: + fields = rows[0].split("|") + return slurm_result(fields[0], fields[1] if len(fields) > 1 else "1:0") + return {"state": "unknown", "detail": "Slurm no longer reports this job and no exit marker was found"} + query = subprocess.run(["qstat", "-xf", raw], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if query.returncode != 0: + query = subprocess.run(["qstat", "-f", raw], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + text = query.stdout + match = next((line.split("=", 1)[1].strip() for line in text.splitlines() if "job_state" in line and "=" in line), "") + code = next((line.split("=", 1)[1].strip() for line in text.splitlines() if "Exit_status" in line and "=" in line), "") + if code.lstrip("-").isdigit(): + return {"state": "done", "code": int(code), "detail": match or "finished"} + if match: + return {"state": "queued" if match in ("Q", "H", "W", "T") else "running", "detail": match} + return {"state": "unknown", "detail": "PBS no longer reports this job and no exit marker was found"} + +def status(token, identifier): + own(token) + if identifier != remote_id(): + raise RuntimeError("OpenScience SSH remote id mismatch") + marker = root / "exit" + if marker.exists(): + response({"state": "done", "code": int(marker.read_text(encoding="utf-8").strip())}) + return + if (root / "cancelled").exists(): + response({"state": "cancelled"}) + return + value = spec() + if identifier.startswith("slurm:") or identifier.startswith("pbs:"): + response(scheduler_status(identifier, value)) + return + value = runtime() + pid = int(identifier.split(":", 1)[1]) + if not value or value["pid"] != pid: + response({"state": "unknown", "detail": "Direct SSH runtime identity is missing"}) + return + if not alive(value) and not scope_empty(value): + response({"state": "running", "detail": "The systemd ownership scope is draining descendants"}) + return + if not alive(value): + response({"state": "unknown", "detail": "Direct SSH process ended without publishing an exit marker"}) + return + response({"state": "running"}) + +def cancel(token, identifier): + own(token) + if identifier != remote_id(): + raise RuntimeError("OpenScience SSH remote id mismatch") + if identifier.startswith("slurm:"): + result = subprocess.run(["scancel", identifier.split(":", 1)[1]], stderr=subprocess.PIPE, text=True) + elif identifier.startswith("pbs:"): + result = subprocess.run(["qdel", identifier.split(":", 1)[1]], stderr=subprocess.PIPE, text=True) + else: + pid = int(identifier.split(":", 1)[1]) + value = runtime() + if not value or value["pid"] != pid: + raise RuntimeError("Refusing to cancel a direct SSH PID without its exact runtime identity") + if (root / "exit").exists() and not alive(value) and scope_empty(value): + atomic("cancelled", "1") + response({"cancelled": True}) + return + if (root / "cancelled").exists() and not alive(value) and scope_empty(value): + response({"cancelled": True}) + return + if alive(value): + try: + os.kill(pid, signal.SIGTERM) + result = subprocess.CompletedProcess([], 0, "", "") + except ProcessLookupError: + result = subprocess.CompletedProcess([], 0, "", "") + elif not scope_empty(value): + result = subprocess.run(["systemctl", "--user", "kill", "--signal=TERM", "--kill-whom=all", value["unit"]], stderr=subprocess.PIPE, text=True) + else: + response({"cancelled": False, "detail": "Direct SSH ownership supervisor disappeared before descendant shutdown was proven"}) + return + if result.returncode != 0: + raise RuntimeError("Remote cancellation failed: " + result.stderr.strip()) + if identifier.startswith("slurm:") or identifier.startswith("pbs:"): + value = spec() + confirmed = False + for _ in range(200): + state = scheduler_status(identifier, value)["state"] + if state in ("cancelled", "done"): + confirmed = True + break + time.sleep(0.1) + if not confirmed: + response({"cancelled": False, "detail": "Scheduler did not report a terminal state after cancellation"}) + return + else: + for attempt in range(600): + value = runtime() + if (root / "cancelled").exists() and not alive(value) and scope_empty(value): + break + if attempt == 100 and alive(value) and hasattr(signal, "SIGUSR1"): + os.kill(pid, signal.SIGUSR1) + if attempt == 100 and not alive(value) and not scope_empty(value): + subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", "--kill-whom=all", value["unit"]], stderr=subprocess.DEVNULL) + time.sleep(0.02) + value = runtime() + if (not (root / "cancelled").exists() and alive(value)) or alive(value) or not scope_empty(value): + response({"cancelled": False, "detail": "Remote ownership supervisor did not prove that every descendant exited"}) + return + if not (root / "cancelled").exists(): + atomic("cancelled", "1") + if not (root / "cancelled").exists(): + atomic("cancelled", "1") + response({"cancelled": True}) + +def logs(token, amount): + own(token) + file = root / "log" + if not file.exists(): + return + with file.open("rb") as handle: + handle.seek(0, os.SEEK_END) + size = handle.tell() + handle.seek(max(0, size - int(amount))) + shutil.copyfileobj(handle, sys.stdout.buffer) + +def harvest(token): + own(token) + value = spec() + work = (root / "work").resolve() + files = {} + for pattern in value.get("outputs") or []: + for item in glob.glob(str(work / pattern), recursive=True): + source = pathlib.Path(item).resolve() + if not source.is_file() or (work != source and work not in source.parents): + continue + relative = source.relative_to(work).as_posix() + if relative in files: + continue + size = source.stat().st_size + digest = hashlib.sha256() + with source.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + files[relative] = {"path": relative, "size": size, "sha256": digest.hexdigest()} + ordered = [files[key] for key in sorted(files)] + if len(ordered) > 200: + raise RuntimeError("SSH outputs exceed the 200 file recovery limit") + if sum(item["size"] for item in ordered) > 20 * 1024 * 1024 * 1024: + raise RuntimeError("SSH outputs exceed the 20 GiB recovery limit") + manifest = json.dumps({"files": ordered}, separators=(",", ":")).encode() + with tarfile.open(fileobj=sys.stdout.buffer, mode="w|") as archive: + info = tarfile.TarInfo("manifest.json") + info.size = len(manifest) + import io + archive.addfile(info, io.BytesIO(manifest)) + for item in ordered: + archive.add(str(work / item["path"]), arcname="files/" + item["path"], recursive=False) + +def release(token): + own(token) + parent = root.parent.resolve() + target = root.resolve() + if parent == target or parent not in target.parents: + raise RuntimeError("Refusing unsafe SSH job release path") + shutil.rmtree(target) + response({"released": True}) + +action = sys.argv[1] +token = sys.argv[2] +if action == "__slurm": response(slurm_result(token, sys.argv[3])) +elif action == "submit": submit(token) +elif action == "status": status(token, sys.argv[3]) +elif action == "cancel": cancel(token, sys.argv[3]) +elif action == "log": logs(token, sys.argv[3]) +elif action == "harvest": harvest(token) +elif action == "release": release(token) +else: raise RuntimeError("Unknown OpenScience SSH control action") +` + + const RECEIVER = String.raw`import hashlib, json, os, pathlib, shutil, sys, tarfile, tempfile +root = pathlib.Path(os.path.expanduser(sys.argv[1])).resolve() +token = sys.argv[2] +owner = hashlib.sha256(token.encode()).hexdigest() +root.parent.mkdir(parents=True, exist_ok=True) +if root.exists(): + saved = (root / "owner").read_text(encoding="utf-8").strip() if (root / "owner").exists() else "" + if saved != owner: raise RuntimeError("OpenScience SSH job ownership mismatch") + if (root / "remote-id").exists(): raise RuntimeError("OpenScience SSH job was already submitted") +else: + root.mkdir(mode=0o700) + (root / "owner").write_text(owner + "\n", encoding="utf-8") +incoming = pathlib.Path(tempfile.mkdtemp(prefix="incoming-", dir=root)) +try: + with tarfile.open(fileobj=sys.stdin.buffer, mode="r|*") as archive: + for member in archive: + name = pathlib.PurePosixPath(member.name) + if name.is_absolute() or ".." in name.parts or not (member.isfile() or member.isdir()): + raise RuntimeError("Unsafe OpenScience SSH staging archive") + target = (incoming / pathlib.Path(*name.parts)).resolve() + if incoming.resolve() != target and incoming.resolve() not in target.parents: + raise RuntimeError("OpenScience SSH staging archive escaped its root") + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: raise RuntimeError("OpenScience SSH staging archive is truncated") + with target.open("wb") as output: shutil.copyfileobj(source, output) + manifest = json.loads((incoming / "inputs.json").read_text(encoding="utf-8")) + for item in manifest["files"]: + file = (incoming / "work" / item["path"]).resolve() + work = (incoming / "work").resolve() + if work != file and work not in file.parents: raise RuntimeError("SSH input escaped its staged workspace") + if not file.is_file() or file.stat().st_size != item["size"]: raise RuntimeError("SSH input size verification failed: " + item["path"]) + digest = hashlib.sha256() + with file.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) + if digest.hexdigest() != item["sha256"]: raise RuntimeError("SSH input checksum verification failed: " + item["path"]) + for name in ("work", "spec.json", "control.py", "supervisor.py", "inputs.json"): + source = incoming / name + target = root / name + if target.exists(): + shutil.rmtree(target) if target.is_dir() else target.unlink() + os.replace(source, target) + (root / "control.py").chmod(0o700) + (root / "supervisor.py").chmod(0o700) + print(json.dumps({"staged": True, "files": len(manifest["files"])})) +finally: + shutil.rmtree(incoming, ignore_errors=True) +` + + function safe(value: string) { + return `'${value.replaceAll("'", `'\"'\"'`)}'` + } + + function env() { + return { + ...Object.fromEntries( + ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "TMPDIR", "SSH_AUTH_SOCK"].flatMap((key) => + process.env[key] ? [[key, process.env[key]!]] : [], + ), + ), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } + } + + async function collect(proc: ReturnType, timeout: number) { + const out: Buffer[] = [] + const err: Buffer[] = [] + proc.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) + proc.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) + const done = new Promise<{ code: number | null; error?: string }>((resolve) => { + proc.once("error", (error) => resolve({ code: null, error: error.message })) + proc.once("exit", (code) => resolve({ code })) + }) + const result = await Promise.race([ + done, + Bun.sleep(timeout).then(() => ({ code: null, error: "Connection timed out" })), + ]) + if (proc.exitCode === null) proc.kill("SIGKILL") + return { + ...result, + stdout: Buffer.concat(out), + stderr: Buffer.concat(err).toString("utf8").trim(), + } + } + + async function hash(file: string) { + const value = new Bun.CryptoHasher("sha256") + for await (const chunk of createReadStream(file)) value.update(chunk) + return value.digest("hex") + } + + async function identify(keygen: string, line: string) { + const child = spawn(keygen, ["-lf", "-", "-E", "sha256"], { + env: env(), + stdio: ["pipe", "pipe", "pipe"], + }) + child.stdin?.end(`${line}\n`) + const result = await collect(child, 5_000) + if (result.code !== 0) throw new Error(result.stderr || "SSH host key fingerprint could not be computed") + const digest = result.stdout.toString("utf8").match(/SHA256:[A-Za-z0-9+/=]+/)?.[0] + if (!digest) throw new Error("SSH host key fingerprint could not be parsed") + return digest + } + + export function destination(host: Host) { + const value = host.user ? `${host.user}@${host.host}` : host.host + if (value.startsWith("-")) throw new Error("SSH destinations cannot begin with a hyphen") + return value + } + + export function argv(host: Host, known: string, script: string) { + const port = host.port ? ["-p", String(host.port)] : [] + return [ + "ssh", + "-T", + "-F", + "/dev/null", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "StrictHostKeyChecking=yes", + "-o", + `UserKnownHostsFile=${known}`, + "-o", + "GlobalKnownHostsFile=/dev/null", + "-o", + "UpdateHostKeys=no", + "-o", + "CheckHostIP=no", + "-o", + "ForwardAgent=no", + "-o", + "ClearAllForwardings=yes", + ...port, + "--", + destination(host), + script, + ] + } + + export async function scan(host: Host) { + const keyscan = Bun.which("ssh-keyscan") + const keygen = Bun.which("ssh-keygen") + if (!keyscan || !keygen) throw new Error("OpenSSH key utilities are required for remote compute") + const base = ["-T", "8", ...(host.port ? ["-p", String(host.port)] : []), host.host] + const scanned = await collect( + spawn(keyscan, ["-t", "ed25519,ecdsa,rsa", ...base], { env: env(), stdio: ["ignore", "pipe", "pipe"] }), + 12_000, + ) + if (scanned.code !== 0 || !scanned.stdout.length) { + throw new Error(scanned.error || scanned.stderr || "SSH host returned no public key") + } + const lines = scanned.stdout + .toString("utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + const line = + lines.find((item) => item.includes(" ssh-ed25519 ")) ?? + lines.find((item) => item.includes(" ecdsa-sha2-nistp256 ")) ?? + lines.find((item) => item.includes(" ecdsa-sha2-nistp384 ")) ?? + lines.find((item) => item.includes(" ecdsa-sha2-nistp521 ")) ?? + lines.find((item) => item.includes(" ssh-rsa ")) ?? + lines[0] + if (!line || !/^(?:\S+)\s+(?:ssh-(?:ed25519|rsa)|ecdsa-)\S*\s+\S+/.test(line)) { + throw new Error("SSH host key response was invalid") + } + return { host_key: line, fingerprint: await identify(keygen, line) } + } + + export async function known(host: Host, root: string) { + if (!host.host_key || !host.fingerprint) throw new Error(`Test ${host.label} once to pin its SSH host key`) + const keygen = Bun.which("ssh-keygen") + if (!keygen) throw new Error("OpenSSH key utilities are required for remote compute") + const fingerprint = await identify(keygen, host.host_key) + if (fingerprint !== host.fingerprint) { + throw new Error(`Pinned SSH host key does not match its saved fingerprint for ${host.label}`) + } + const folder = path.join(root, "ssh-hosts") + const file = path.join(folder, `${crypto.createHash("sha256").update(host.id).digest("hex")}.known_hosts`) + await fs.mkdir(folder, { recursive: true }) + await fs.writeFile(file, `${host.host_key.trim()}\n`, { mode: 0o600 }) + await fs.chmod(file, 0o600) + return file + } + + export function invoke( + spec: Spec, + action: "submit" | "status" | "cancel" | "log" | "harvest" | "release", + ...args: string[] + ) { + const bootstrap = `import os,sys; root=os.path.abspath(os.path.expanduser(sys.argv[1])); os.execv(sys.executable,[sys.executable,os.path.join(root,'control.py'),*sys.argv[2:]])` + return `python3 -c ${safe(bootstrap)} ${safe(spec.root)} ${safe(action)} ${safe(spec.owner)}${args.map((value) => ` ${safe(value)}`).join("")}` + } + + export function receive(spec: Spec) { + return `python3 -c ${safe(RECEIVER)} ${safe(spec.root)} ${safe(spec.owner)}` + } + + export function inspect(spec: Spec) { + const script = + "import json,os,sys; root=os.path.abspath(os.path.expanduser(sys.argv[1])); print(json.dumps({'exists':os.path.isfile(os.path.join(root,'control.py'))},separators=(',',':')))" + return `python3 -c ${safe(script)} ${safe(spec.root)}` + } + + export async function archive(spec: Spec, directory: string) { + const root = await fs.mkdtemp(path.join(directory, `${spec.id}.ssh-stage-`)) + const work = path.join(root, "work") + await fs.mkdir(work, { recursive: true }) + await Promise.all( + spec.uploads.map(async (file) => { + const current = await fs.realpath(file.canonical).catch(() => undefined) + if (!current || current !== file.canonical || (await hash(current)) !== file.sha256) { + throw new Error(`SSH input changed after approval: ${file.path}`) + } + const target = path.resolve(work, file.path) + if (work !== target && !target.startsWith(`${work}${path.sep}`)) + throw new Error(`SSH input escaped staging: ${file.path}`) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.copyFile(current, target) + const info = await fs.stat(target) + if (info.size !== file.size || (await hash(target)) !== file.sha256) { + throw new Error(`SSH input staging integrity check failed: ${file.path}`) + } + }), + ) + const manifest = { files: spec.uploads.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })) } + await Promise.all([ + fs.writeFile(path.join(root, "inputs.json"), JSON.stringify(manifest), { mode: 0o600 }), + fs.writeFile(path.join(root, "spec.json"), JSON.stringify({ ...spec, uploads: undefined, owner: undefined }), { + mode: 0o600, + }), + fs.writeFile(path.join(root, "control.py"), CONTROL, { mode: 0o700 }), + fs.writeFile(path.join(root, "supervisor.py"), SUPERVISOR, { mode: 0o700 }), + ]) + const tar = path.join(directory, `${spec.id}.${crypto.randomUUID()}.tar`) + const proc = Bun.spawn(["tar", "-cf", tar, "-C", root, "."], { stdout: "ignore", stderr: "pipe" }) + const [code, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + await fs.rm(root, { recursive: true, force: true }) + if (code !== 0) { + await fs.rm(tar, { force: true }) + throw new Error(`Could not package SSH inputs: ${error.trim()}`) + } + return tar + } + + export function parse(buffer: Buffer): T { + const text = buffer.toString("utf8").trim() + if (!text) throw new Error("SSH control command returned no response") + return JSON.parse(text.split("\n").at(-1)!) as T + } + + export async function slurm(state: string, exit = "1:0"): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-slurm-state-")) + const script = path.join(root, "control.py") + try { + await fs.writeFile(script, CONTROL, { mode: 0o700 }) + const proc = spawn("python3", [script, "__slurm", state, exit], { stdio: ["ignore", "pipe", "pipe"] }) + const result = await collect(proc, 5_000) + if (result.code !== 0) throw new Error(result.stderr || "Slurm state parser failed") + return parse(result.stdout) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + } + + async function member(archive: string, name: string, target?: string) { + const output = target ? await fs.open(target, "w", 0o600) : undefined + try { + const proc = spawn("tar", ["-xOf", archive, "--", name], { + stdio: ["ignore", output?.fd ?? "pipe", "pipe"], + }) + const chunks: Buffer[] = [] + const errors: Buffer[] = [] + proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk)) + proc.stderr?.on("data", (chunk: Buffer) => errors.push(chunk)) + const code = await new Promise((resolve) => { + proc.once("error", () => resolve(null)) + proc.once("exit", resolve) + }) + if (code !== 0) + throw new Error(Buffer.concat(errors).toString("utf8").trim() || `SSH output archive is missing ${name}`) + return Buffer.concat(chunks) + } finally { + await output?.close().catch(() => undefined) + } + } + + async function install(root: string, staging: string, files: Manifest["files"]) { + const proc = spawn("python3", ["-c", BROKER, root, staging], { + stdio: ["pipe", "pipe", "pipe"], + }) + const errors: Buffer[] = [] + proc.stderr?.on("data", (chunk: Buffer) => errors.push(chunk)) + proc.stdin?.end(JSON.stringify({ files })) + const code = await new Promise((resolve) => { + proc.once("error", () => resolve(null)) + proc.once("exit", resolve) + }) + if (code === 0) return + const detail = Buffer.concat(errors) + .toString("utf8") + .trim() + .split("\n") + .at(-1) + ?.replace(/^RuntimeError: /, "") + throw new Error(detail || "SSH output installation broker failed") + } + + export async function deliver(archive: string, root: string) { + const parsed: unknown = JSON.parse((await member(archive, "manifest.json")).toString("utf8")) + const manifest = parsed as Partial + if (!Array.isArray(manifest.files)) throw new Error("SSH output archive has no valid manifest") + const files = manifest.files.map((item) => { + if ( + !item || + typeof item.path !== "string" || + !item.path || + path.posix.isAbsolute(item.path) || + item.path.split("/").some((part) => !part || part === "." || part === "..") || + typeof item.size !== "number" || + !Number.isSafeInteger(item.size) || + item.size < 0 || + typeof item.sha256 !== "string" || + !/^[a-f0-9]{64}$/.test(item.sha256) + ) { + throw new Error("SSH output archive manifest is invalid") + } + return item + }) + if (files.length > 200 || new Set(files.map((item) => item.path)).size !== files.length) { + throw new Error("SSH output archive manifest has too many or duplicate files") + } + if (files.reduce((sum, item) => sum + item.size, 0) > 20 * 1024 * 1024 * 1024) { + throw new Error("SSH outputs exceed the 20 GiB recovery limit") + } + const staging = await fs.mkdtemp(path.join(path.dirname(archive), "ssh-delivery-")) + try { + for (const item of files) { + const staged = path.resolve(staging, item.path) + if (staging !== staged && !staged.startsWith(`${staging}${path.sep}`)) { + throw new Error(`SSH output escaped local staging: ${item.path}`) + } + await fs.mkdir(path.dirname(staged), { recursive: true }) + await member(archive, `files/${item.path}`, staged) + const info = await fs.stat(staged) + if (info.size !== item.size || (await hash(staged)) !== item.sha256) { + throw new Error(`SSH output failed integrity verification: ${item.path}`) + } + } + await install(root, staging, files) + return files.map((item) => ({ ...item, modified_at: new Date().toISOString() })) + } finally { + await fs.rm(staging, { recursive: true, force: true }) + } + } +} diff --git a/backend/cli/src/compute/ssh/plan.ts b/backend/cli/src/compute/ssh/plan.ts new file mode 100644 index 00000000..cc25e853 --- /dev/null +++ b/backend/cli/src/compute/ssh/plan.ts @@ -0,0 +1,130 @@ +import path from "node:path" +import z from "zod" +import { ModalPlan } from "../modal/plan" +import type { ModalAdapter } from "../modal/adapter" + +export namespace SshPlan { + export const Upload = z.object({ + path: z.string(), + size: z.number().int().nonnegative(), + sha256: z.string().length(64), + }) + + export const Schema = z.object({ + digest: z.string().length(64), + provider: z.literal("ssh"), + host_id: z.string(), + host: z.string(), + user: z.string().optional(), + port: z.number().int().positive().max(65_535).optional(), + label: z.string(), + scheduler: z.enum(["none", "slurm", "pbs"]), + fingerprint: z.string().startsWith("SHA256:"), + command: z.string(), + resources: z + .object({ + cpus: z.number().int().positive().optional(), + gpus: z.number().int().nonnegative().optional(), + memory_gb: z.number().int().positive().optional(), + time_minutes: z.number().int().positive().optional(), + partition: z.string().optional(), + }) + .optional(), + modules: z.string().array().optional(), + container: z.string().optional(), + local_cwd: z.string(), + remote_base: z.string(), + remote_root: z.string(), + remote_cwd: z.string(), + uploads: Upload.array(), + upload_bytes: z.number().int().nonnegative(), + outputs: z.string().array(), + warning: z.string(), + }) + export type Schema = z.infer + + export type Host = { + id: string + label: string + host: string + user?: string + port?: number + scheduler: "none" | "slurm" | "pbs" + workdir?: string + fingerprint?: string + host_key?: string + } + + export type Input = { + id: string + command: string + resources?: { + cpus?: number + gpus?: number + memory_gb?: number + time_minutes?: number + partition?: string + } + modules?: string[] + container?: string + cwd: string + remoteCwd?: string + uploads: string[] + outputs: string[] + host: Host + } + + export type Prepared = { plan: Schema; files: ModalAdapter.File[] } + + function clean(value: string | undefined) { + const current = value?.trim().replaceAll("\\", "/").replace(/^\.\//, "") || "." + if (path.posix.isAbsolute(current) || current.split("/").includes("..")) { + throw new Error(`SSH working directory must stay inside the staged job workspace: ${value}`) + } + return current === "" ? "." : current + } + + export function remoteRoot(host: Host, id: string) { + return `${remoteBase(host)}/.openscience/jobs/${id}` + } + + export function remoteBase(host: Host) { + return host.workdir?.trim().replace(/\/+$/, "") || "~" + } + + export async function prepare(input: Input): Promise { + if (!input.host.host_key || !input.host.fingerprint) { + throw new Error(`Test ${input.host.label} once to pin its SSH host key before dispatch`) + } + const upload = await ModalPlan.files(input.cwd, input.uploads, "SSH") + const value = { + provider: "ssh" as const, + host_id: input.host.id, + host: input.host.host, + user: input.host.user, + port: input.host.port, + label: input.host.label, + scheduler: input.host.scheduler, + fingerprint: input.host.fingerprint, + command: input.command, + resources: input.resources, + modules: input.modules, + container: input.container, + local_cwd: input.cwd, + remote_base: remoteBase(input.host), + remote_root: remoteRoot(input.host, input.id), + remote_cwd: clean(input.remoteCwd), + uploads: upload.files.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })), + upload_bytes: upload.bytes, + outputs: input.outputs.toSorted(), + warning: `This command will run on ${input.host.label} through your SSH agent. OpenScience pins ${input.host.fingerprint}, stages only the reviewed inputs, and downloads only declared outputs.`, + } + // The durable job id (and therefore its isolated remote folder) is minted + // only after approval. The reviewed security/workload contract is stable + // across that minting step; the server-generated folder is not user input. + const digest = new Bun.CryptoHasher("sha256") + .update(JSON.stringify({ ...value, remote_root: undefined })) + .digest("hex") + return { plan: Schema.parse({ digest, ...value }), files: upload.files } + } +} diff --git a/backend/cli/src/config/config.ts b/backend/cli/src/config/config.ts index 4f7372c5..c817c90a 100644 --- a/backend/cli/src/config/config.ts +++ b/backend/cli/src/config/config.ts @@ -268,15 +268,25 @@ export namespace Config { }, }) } + for (const [name, mode] of Object.entries(execution.mode ?? {})) { + execution.agent = mergeDeep(execution.agent ?? {}, { + [name]: { + ...mode, + mode: "primary" as const, + }, + }) + } if (Flag.OPENSCIENCE_PERMISSION) { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENSCIENCE_PERMISSION)) + execution.permission = mergeDeep(execution.permission ?? {}, JSON.parse(Flag.OPENSCIENCE_PERMISSION)) } // Backwards compatibility: legacy top-level `tools` config - if (result.tools) { + for (const target of [result, execution]) { + if (!target.tools) continue const perms: Record = {} - for (const [tool, enabled] of Object.entries(result.tools)) { + for (const [tool, enabled] of Object.entries(target.tools)) { const action: Config.PermissionAction = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { perms.edit = action @@ -284,7 +294,7 @@ export namespace Config { } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + target.permission = mergeDeep(perms, target.permission ?? {}) } if (!result.username) result.username = os.userInfo().username @@ -1522,6 +1532,12 @@ export namespace Config { if (await ProjectTrust.allowed(Instance.project)) return current.config return { ...current.config, + command: current.execution.command, + agent: current.execution.agent, + mode: current.execution.mode, + default_agent: current.execution.default_agent, + permission: current.execution.permission, + tools: current.execution.tools, plugin: current.execution.plugin, mcp: current.execution.mcp, formatter: current.execution.formatter, @@ -1547,6 +1563,36 @@ export namespace Config { return JSON.stringify(value(current.config)) !== JSON.stringify(value(current.execution)) } + /** Whether an exact provider token command entered through project-owned + * config. Unlike getExecution(), the baseline remains project-free after a + * project is trusted, so cached provider clients can keep enforcing trust at + * every later mint boundary. */ + export async function projectControlsProviderToken(providerID: string, command: string) { + const current = await state() + const value = (config: Info) => config.provider?.[providerID]?.options?.tokenCommand + return value(current.config) === command && value(current.execution) !== command + } + + /** Whether this exact plugin entry entered through project-owned config or a + * project-local plugin directory. The execution baseline contains only + * remote, global, custom-CLI, synced, and managed sources, so comparing the + * final deduplicated entries preserves provenance even when a local plugin + * overrides a global plugin with the same package name. */ + export async function projectControlsPlugin(plugin: string) { + const current = await state() + const all = current.config.plugin ?? [] + const trusted = current.execution.plugin ?? [] + return all.includes(plugin) && !trusted.includes(plugin) + } + + /** Whether an MCP definition entered through project-owned config. Kept as + * provenance so a tool object retained across revocation can re-check trust + * at its actual remote call boundary. */ + export async function projectControlsMcp(name: string) { + const current = await state() + return JSON.stringify(current.config.mcp?.[name]) !== JSON.stringify(current.execution.mcp?.[name]) + } + export async function getGlobal() { return global() } diff --git a/backend/cli/src/credentials/lifecycle.ts b/backend/cli/src/credentials/lifecycle.ts new file mode 100644 index 00000000..ef733053 --- /dev/null +++ b/backend/cli/src/credentials/lifecycle.ts @@ -0,0 +1,256 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "../global" +import { FileLease } from "../util/file-lease" +import { Log } from "../util/log" +import { DataRootBarrier } from "../global/data-root-barrier" + +/** + * Cross-process credential revision barrier. + * + * OpenScience commonly has two live servers (the installed build and a dev + * build) sharing one data directory. Environment variables and child-process + * environments are process-local snapshots, so changing a credential in one + * server must invalidate the other server before it can launch more work. + * + * Writers publish an `updating` marker before touching a credential store and + * a `ready` marker after the durable write. Readers check this marker at every + * credential-bearing spawn boundary. Seeing `updating` blocks the spawn; seeing + * a new ready token refreshes process-local state and revokes children that may + * have inherited the previous snapshot. + */ +export namespace CredentialLifecycle { + const log = Log.create({ service: "credential-lifecycle" }) + const revisionFile = path.join(Global.Path.data, "credential-revision.json") + const mutationLock = `${revisionFile}.lock` + const waitTimeout = 10_000 + + type Phase = "updating" | "ready" + interface Revision { + version: 1 + token: string + phase: Phase + reason: string + pid: number + updated_at: string + } + + export interface Event { + token: string + reason: string + pid: number + } + + type Handler = (event: Event) => void | Promise + const refreshers = new Set() + const revokers = new Set() + let seen: string | null | undefined + let checking: Promise | undefined + let reconciliation: Promise = Promise.resolve() + let timer: ReturnType | undefined + + function parse(value: unknown): Revision { + if (!value || typeof value !== "object") throw new Error("credential revision is not an object") + const item = value as Partial + if ( + item.version !== 1 || + typeof item.token !== "string" || + !item.token || + (item.phase !== "updating" && item.phase !== "ready") || + typeof item.reason !== "string" || + typeof item.pid !== "number" || + typeof item.updated_at !== "string" + ) { + throw new Error("credential revision has an invalid shape") + } + return item as Revision + } + + async function read(): Promise { + const text = await fs.readFile(revisionFile, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + if (text === undefined) return null + return parse(JSON.parse(text)) + } + + async function publish(revision: Revision): Promise { + await using operation = await DataRootBarrier.enter(revisionFile) + const temp = `${revisionFile}.${process.pid}.${crypto.randomUUID()}.tmp` + await fs.mkdir(path.dirname(revisionFile), { recursive: true }) + const handle = await fs.open(temp, "wx", 0o600) + await handle + .chmod(0o600) + .then(() => handle.writeFile(JSON.stringify(revision, null, 2), "utf8")) + .then(() => handle.sync()) + .finally(() => handle.close()) + .catch(async (error) => { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + }) + await fs.rename(temp, revisionFile).catch(async (error) => { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + }) + const directory = await fs.open(path.dirname(revisionFile), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) + } + + async function waitUntilReady(initial: Revision): Promise { + let current = initial + const started = Date.now() + while (current.phase === "updating") { + if (Date.now() - started >= waitTimeout) { + throw new Error( + `Credential mutation ${current.token} did not finish; refusing to launch a process with an unverified credential snapshot`, + ) + } + await Bun.sleep(15) + const next = await read() + if (!next) throw new Error("Credential revision disappeared while a mutation was in progress") + current = next + } + return current + } + + async function run(handlers: Set, event: Event): Promise { + const results = await Promise.allSettled([...handlers].map((handler) => handler(event))) + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) throw new AggregateError(failures, "Credential invalidation did not complete") + } + + async function reconcile(revision: Revision, local = false): Promise { + const task = reconciliation.then(async () => { + if (!local && seen === revision.token) return false + const event = { token: revision.token, reason: revision.reason, pid: revision.pid } + await run(refreshers, event) + await run(revokers, event) + seen = revision.token + return true + }) + reconciliation = task.then( + () => undefined, + () => undefined, + ) + return task + } + + /** Register process-local state that must be reloaded before any new child. */ + export function onRefresh(handler: Handler): () => void { + refreshers.add(handler) + return () => refreshers.delete(handler) + } + + /** Register long-lived children/caches that inherited the old snapshot. */ + export function onRevoke(handler: Handler): () => void { + revokers.add(handler) + return () => revokers.delete(handler) + } + + /** Serialize credential-adjacent metadata writes without publishing a revision. */ + export async function serialized(action: () => T | Promise): Promise { + await using lease = await FileLease.acquire(mutationLock) + return await action() + } + + /** Hold the cross-process mutation lease from freshness check through child + * spawn and durable owner registration, closing the snapshot-to-spawn race. */ + export async function admit(action: () => T | Promise): Promise { + await using lease = await FileLease.acquire(mutationLock) + await ensureFresh() + return await action() + } + + /** + * Check the durable revision. A first call with an existing marker also + * reconciles: another server may have committed between module preload and + * server startup. Failures block the caller. + */ + export async function ensureFresh(): Promise { + if (checking) return checking + checking = (async () => { + const current = await read() + if (!current) { + if (seen === undefined) seen = null + return false + } + const ready = await waitUntilReady(current) + if (seen === undefined) { + return reconcile(ready, true) + } + if (seen === ready.token) return false + return reconcile(ready) + })().finally(() => { + checking = undefined + }) + return checking + } + + /** + * Serialize and publish a credential-bearing mutation. The updating marker + * is visible before `action` runs, closing the store-write/revision race. + */ + export async function mutate( + reason: string, + action: () => T | Promise, + options: { reconcileLocal?: boolean } = {}, + ): Promise { + let ready!: Revision + let value: T | undefined + let failure: unknown + let failed = false + { + await using lease = await FileLease.acquire(mutationLock) + const token = crypto.randomUUID() + const base = { + version: 1 as const, + token, + reason, + pid: process.pid, + } + await publish({ ...base, phase: "updating", updated_at: new Date().toISOString() }) + + try { + value = await action() + } catch (error) { + failed = true + failure = error + } + + ready = { ...base, phase: "ready", updated_at: new Date().toISOString() } + await publish(ready) + } + + if (options.reconcileLocal === false) seen = ready.token + else await reconcile(ready, true) + if (failed) throw failure + return value as T + } + + /** Start a low-cost process-local watcher; spawn boundaries still check synchronously. */ + export function watch(interval = 100): () => void { + if (!timer) { + void ensureFresh().catch((error) => log.warn("credential revision baseline failed", { error })) + timer = setInterval( + () => { + void ensureFresh().catch((error) => log.error("credential revision reconciliation failed", { error })) + }, + Math.max(25, interval), + ) + timer.unref() + } + return stopWatching + } + + export function stopWatching(): void { + if (timer) clearInterval(timer) + timer = undefined + } + + /** Exposed for narrow integration tests and sandbox deny-list construction. */ + export function revisionPath(): string { + return revisionFile + } +} diff --git a/backend/cli/src/credentials/process-ledger.ts b/backend/cli/src/credentials/process-ledger.ts new file mode 100644 index 00000000..b265dfa0 --- /dev/null +++ b/backend/cli/src/credentials/process-ledger.ts @@ -0,0 +1,623 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "../global" +import { DataRootBarrier } from "../global/data-root-barrier" +import { DarwinResponsibility } from "../process/darwin-responsibility" +import { DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX } from "../process/darwin-responsibility-launcher" +import { WindowsJob } from "../process/windows-job" +import { AuthorityProcessLedger } from "../project/authority-process" +import { FileLease } from "../util/file-lease" + +export namespace CredentialProcessLedger { + export type Kind = "command" | "compute" | "lsp" | "mcp" | "provider" | "modal-volume" | "local-runtime" + + interface Entry { + version: 1 + id: string + kind: Kind + pid: number + identity: string + detached: boolean + darwin_responsibility_uniqueid?: string + windows_job?: string + owner_pid: number + created_at: string + project_id?: string + session_id?: string + authority_generation?: string + } + + export interface Scope { + id?: string + kind?: Kind + projectID?: string + sessionID?: string + } + + export interface RevokeOptions { + /** Invoked once after the exact live group/descendant identities are + * pinned, but before they are signalled. Callers may update application + * state or perform their existing stop callback here without creating a + * leader-exit/reparenting gap in durable teardown. */ + onPinned?: (id: string) => Promise + } + + const filepath = path.join(Global.Path.data, "credential-processes.json") + const lockpath = `${filepath}.lock` + + function valid(value: unknown): value is Entry { + if (!value || typeof value !== "object") return false + const item = value as Partial + return ( + item.version === 1 && + typeof item.id === "string" && + !!item.id && + (item.kind === "command" || + item.kind === "compute" || + item.kind === "lsp" || + item.kind === "mcp" || + item.kind === "provider" || + item.kind === "modal-volume" || + item.kind === "local-runtime") && + typeof item.pid === "number" && + Number.isSafeInteger(item.pid) && + item.pid > 0 && + typeof item.identity === "string" && + /^[a-f0-9]{64}$/.test(item.identity) && + typeof item.detached === "boolean" && + (item.darwin_responsibility_uniqueid === undefined || + (typeof item.darwin_responsibility_uniqueid === "string" && + /^[1-9][0-9]{0,19}$/.test(item.darwin_responsibility_uniqueid))) && + (item.windows_job === undefined || WindowsJob.valid(item.windows_job)) && + typeof item.owner_pid === "number" && + Number.isSafeInteger(item.owner_pid) && + typeof item.created_at === "string" && + (item.project_id === undefined || typeof item.project_id === "string") && + (item.session_id === undefined || typeof item.session_id === "string") && + (item.authority_generation === undefined || typeof item.authority_generation === "string") + ) + } + + async function read(): Promise { + const text = await fs.readFile(filepath, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + if (text === undefined) return [] + const parsed: unknown = JSON.parse(text) + if (!Array.isArray(parsed) || !parsed.every(valid)) { + throw new Error(`Credential process ledger ${filepath} is corrupt; refusing unsafe process revocation`) + } + return parsed + } + + async function write(entries: Entry[]): Promise { + await using operation = await DataRootBarrier.enter(filepath) + const temp = `${filepath}.${process.pid}.${crypto.randomUUID()}.tmp` + await fs.mkdir(path.dirname(filepath), { recursive: true }) + try { + const handle = await fs.open(temp, "wx", 0o600) + await handle + .chmod(0o600) + .then(() => handle.writeFile(JSON.stringify(entries, null, 2), "utf8")) + .then(() => handle.sync()) + .finally(() => handle.close()) + await fs.rename(temp, filepath) + const directory = await fs.open(path.dirname(filepath), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + } + } + + function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } + } + + function processEnv(): Record { + const keys = ["PATH", "SYSTEMROOT", "WINDIR", "PATHEXT", "TMP", "TEMP"] + return Object.fromEntries(keys.flatMap((key) => (process.env[key] ? [[key, process.env[key]!]] : []))) + } + + /** Stable OS process-start identity, hashed before persistence. */ + export async function identity(pid: number): Promise { + return AuthorityProcessLedger.identity(pid) + } + + export async function owns(pid: number, expected: string | undefined): Promise { + return AuthorityProcessLedger.owns(pid, expected) + } + + function linuxProcess(stat: string) { + const close = stat.lastIndexOf(")") + if (close < 0) return + const fields = stat + .slice(close + 2) + .trim() + .split(/\s+/) + const ppid = Number(fields[1]) + const pgid = Number(fields[2]) + if (!Number.isSafeInteger(ppid) || ppid < 0 || !Number.isSafeInteger(pgid) || pgid <= 0) return + return { ppid, pgid } + } + + async function linuxProcessFor(pid: number) { + const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ESRCH") return undefined + throw error + }) + return stat ? linuxProcess(stat) : undefined + } + + async function darwinProcess(pid: number): Promise<{ ppid: number; pgid: number } | undefined> { + const { dlopen, FFIType, ptr } = await import("bun:ffi") + const lib = dlopen("/usr/lib/libproc.dylib", { + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + }) + try { + const info = Buffer.alloc(136) + const size = lib.symbols.proc_pidinfo(pid, 3, 0n, ptr(info), info.length) + if (size !== info.length || info.readUInt32LE(12) !== pid) return + return { ppid: info.readUInt32LE(16), pgid: info.readUInt32LE(100) } + } finally { + lib.close() + } + } + + async function processGroup(pid: number): Promise { + if (process.platform === "linux") return (await linuxProcessFor(pid))?.pgid + if (process.platform === "darwin") return (await darwinProcess(pid))?.pgid + } + + async function leadsOwnGroup(pid: number): Promise { + if (process.platform === "win32") return false + return (await processGroup(pid)) === pid + } + + interface Member { + pid: number + identity: string + groupBound: boolean + responsibilityBound: boolean + } + + interface ProcessRow { + pid: number + ppid: number + pgid: number + } + + async function processTable(): Promise { + if (process.platform === "linux") { + const names = await fs.readdir("/proc") + const result: ProcessRow[] = [] + for (const name of names) { + if (!/^\d+$/.test(name)) continue + const pid = Number(name) + const info = await linuxProcessFor(pid) + if (info) result.push({ pid, ppid: info.ppid, pgid: info.pgid }) + } + return result + } + if (process.platform === "darwin") { + const proc = Bun.spawn(["/bin/ps", "-axo", "pid=,ppid=,pgid="], { + env: processEnv(), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Could not enumerate credential-bearing processes: ${stderr.trim()}`) + return stdout + .split("\n") + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter( + ([pid, ppid, pgid]) => + Number.isSafeInteger(pid) && + pid! > 0 && + Number.isSafeInteger(ppid) && + ppid! >= 0 && + Number.isSafeInteger(pgid) && + pgid! > 0, + ) + .map(([pid, ppid, pgid]) => ({ pid: pid!, ppid: ppid!, pgid: pgid! })) + } + throw new Error(`Durable credential process-group teardown is unsupported on ${process.platform}`) + } + + /** Capture every current group member plus the exact live descendant + * closure. The latter catches direct setsid()/start_new_session escapes + * while the registered leader is still alive. A POSIX PGID cannot be reused + * as a PID while the original process group still has members. */ + async function groupMembers(entry: Entry): Promise<{ members: Member[]; unverified: boolean }> { + const currentLeader = await identity(entry.pid) + if (currentLeader && currentLeader !== entry.identity) return { members: [], unverified: false } + const rows = await processTable() + const selected = new Map() + for (const row of rows) { + if (row.pgid === entry.pid) selected.set(row.pid, true) + } + if (currentLeader === entry.identity) { + const descendants = new Set([entry.pid]) + let changed = true + while (changed) { + changed = false + for (const row of rows) { + if (descendants.has(row.pid) || !descendants.has(row.ppid)) continue + descendants.add(row.pid) + selected.set(row.pid, row.pgid === entry.pid) + changed = true + } + } + } + const responsible = new Set( + entry.darwin_responsibility_uniqueid + ? DarwinResponsibility.uniqueMembers(entry.darwin_responsibility_uniqueid) + : [], + ) + for (const pid of responsible) selected.set(pid, selected.get(pid) ?? false) + const members: Member[] = [] + let unverified = !currentLeader && alive(entry.pid) && (await processGroup(entry.pid)) === entry.pid + for (const [pid, groupBound] of selected) { + const memberIdentity = await identity(pid) + if (!memberIdentity) { + if ((!groupBound || (await processGroup(pid)) === entry.pid) && alive(pid)) { + // A just-signalled child may remain briefly as a zombie: it still + // has a PID/PGID but no libproc identity. Do not authenticate or + // signal it, and do not call the group empty. Retry until it is + // reaped; persistent opacity fails closed at the teardown timeout. + unverified = true + } + continue + } + if (groupBound && (await processGroup(pid)) !== entry.pid) continue + if (!(await owns(pid, memberIdentity))) continue + if (pid === entry.pid && memberIdentity !== entry.identity) return { members: [], unverified: false } + members.push({ pid, identity: memberIdentity, groupBound, responsibilityBound: responsible.has(pid) }) + } + return { members, unverified } + } + + async function signalMember(entry: Entry, member: Member, responsibilityPinned = false): Promise { + if (!(await owns(member.pid, member.identity))) return false + if (member.groupBound && (await processGroup(member.pid)) !== entry.pid) return false + if ( + member.responsibilityBound && + (!entry.darwin_responsibility_uniqueid || + (!DarwinResponsibility.uniquelyOwns(entry.darwin_responsibility_uniqueid, member.pid) && !responsibilityPinned)) + ) { + return false + } + if (member.pid === entry.pid && member.identity !== entry.identity) return false + try { + process.kill(member.pid, "SIGKILL") + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false + throw error + } + } + + async function teardownGroup(entry: Entry, options: RevokeOptions = {}): Promise { + if (process.platform === "win32") { + if (!entry.windows_job) { + throw new Error(`Credential-bearing ${entry.kind} process ${entry.pid} predates Windows Job Object ownership`) + } + const live = await owns(entry.pid, entry.identity) + if (live) await options.onPinned?.(entry.id) + const terminated = WindowsJob.terminate(entry.windows_job) + if (live && !terminated && (await owns(entry.pid, entry.identity))) { + throw new Error(`Windows Job Object ${entry.windows_job} disappeared while process ${entry.pid} remained alive`) + } + return live || terminated + } + if (!entry.detached) { + throw new Error(`Credential-bearing ${entry.kind} process ${entry.pid} has no safely reapable process group`) + } + let signalled = false + let pinned = false + // A trusted onPinned callback may stop the supervisor root after this + // kernel-owned snapshot. macOS can then reassign a surviving child's live + // responsibility value. Retain its exact process-start identity so the + // already-proven incarnation stays signalable and is verified gone before + // teardown returns. + const pinnedResponsibility = new Map() + for (let attempt = 0; attempt < 100; attempt++) { + const snapshot = await groupMembers(entry) + for (const member of snapshot.members) { + if (member.responsibilityBound) pinnedResponsibility.set(member.pid, member.identity) + } + for (const [pid, memberIdentity] of pinnedResponsibility) { + if (!(await owns(pid, memberIdentity))) { + pinnedResponsibility.delete(pid) + continue + } + if (!snapshot.members.some((member) => member.pid === pid)) { + snapshot.members.push({ + pid, + identity: memberIdentity, + groupBound: false, + responsibilityBound: true, + }) + } + } + if (!snapshot.members.length && !snapshot.unverified) return signalled + if (!pinned && snapshot.members.length) { + await options.onPinned?.(entry.id) + pinned = true + } + // Preserve the exact leader until its descendants are authenticated and + // signalled. That pins the group identity throughout normal revocation. + snapshot.members.sort((a, b) => Number(a.pid === entry.pid) - Number(b.pid === entry.pid)) + for (const member of snapshot.members) { + signalled = + (await signalMember(entry, member, pinnedResponsibility.get(member.pid) === member.identity)) || signalled + } + await Bun.sleep(20) + } + const remaining = await groupMembers(entry) + throw new Error( + `Credential-bearing ${entry.kind} process group ${entry.pid} did not exit (${remaining.members.length} verified members${remaining.unverified ? " plus unverified members" : ""} remain)`, + ) + } + + export async function register(input: { + id: string + kind: Kind + pid: number + detached: boolean + identity?: string + projectID?: string + sessionID?: string + authorityGeneration?: string + windowsRelease?: string + }): Promise { + if ((process.platform === "win32" || process.platform === "darwin") && !input.windowsRelease) { + throw new Error( + `Credential-bearing ${input.kind} child ${input.pid} was not launched behind the ${process.platform === "win32" ? "Windows Job Object" : "macOS responsibility"} registration gate`, + ) + } + if (process.platform === "darwin" && !DarwinResponsibility.available()) { + throw new Error("macOS responsibility APIs are unavailable; refusing durable process registration") + } + const processIdentity = input.identity ?? (await identity(input.pid)) + if (!processIdentity) { + if (!alive(input.pid)) return false + throw new Error(`Could not establish a safe process identity for credential-bearing child ${input.pid}`) + } + const requiresGroup = + input.kind === "command" || + input.kind === "compute" || + input.kind === "lsp" || + input.kind === "mcp" || + input.kind === "provider" || + input.kind === "modal-volume" || + input.kind === "local-runtime" + if (requiresGroup && process.platform !== "win32" && !input.detached) { + throw new Error(`Credential-bearing ${input.kind} child ${input.pid} was not spawned in an owned process group`) + } + if (process.platform !== "win32" && input.detached && !(await leadsOwnGroup(input.pid))) { + throw new Error( + `Credential-bearing ${input.kind} child ${input.pid} is not its own process-group leader; refusing an unreapable spawn`, + ) + } + // Close the capture/check window before publishing durable ownership. + if (!(await owns(input.pid, processIdentity))) return false + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const index = entries.findIndex((entry) => entry.id === input.id) + // Replacing an ID without first closing its named Job would leave the old + // tree contained but unreachable from the durable ledger. + if ((process.platform === "win32" || process.platform === "darwin") && index >= 0) { + await teardownGroup(entries[index]!) + } + let darwinResponsibility: string | undefined + const windowsJob = + process.platform === "win32" + ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) + : undefined + const next: Entry = { + version: 1, + id: input.id, + kind: input.kind, + pid: input.pid, + detached: input.detached, + ...(windowsJob ? { windows_job: windowsJob } : {}), + identity: processIdentity, + owner_pid: process.pid, + created_at: new Date().toISOString(), + ...(input.projectID ? { project_id: input.projectID } : {}), + ...(input.sessionID ? { session_id: input.sessionID } : {}), + ...(input.authorityGeneration ? { authority_generation: input.authorityGeneration } : {}), + } + if (index < 0) entries.push(next) + else entries[index] = next + await write(entries).catch((error) => { + if (windowsJob) WindowsJob.terminate(windowsJob) + throw error + }) + if (windowsJob && input.windowsRelease) { + try { + WindowsJob.release(input.windowsRelease, input.pid) + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (process.platform === "darwin" && input.windowsRelease) { + try { + await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + for (let attempt = 0; attempt < 3_000; attempt++) { + if (!(await owns(input.pid, processIdentity))) break + if (DarwinResponsibility.responsible(input.pid) === input.pid) { + darwinResponsibility = DarwinResponsibility.unique(input.pid) + if (darwinResponsibility) break + } + if (attempt === 2_999) { + throw new Error( + `Credential-bearing ${input.kind} child ${input.pid} did not become a macOS responsibility root`, + ) + } + await Bun.sleep(10) + } + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility) { + next.darwin_responsibility_uniqueid = darwinResponsibility + const position = entries.findIndex((entry) => entry.id === input.id) + if (position >= 0) entries[position] = next + await write(entries) + try { + await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }) + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw new Error(`Credential-bearing ${input.kind} child ${input.pid} failed macOS responsibility handoff`) + } + // Persist first, then close the final observation window. If the leader + // exited during publication, durable ownership already exists and can + // reap every surviving original-group member before reporting a failed + // spawn. A teardown failure deliberately leaves the entry on disk. + if ( + !(await owns(input.pid, processIdentity)) || + (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || + (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) + ) { + if (next.detached || windowsJob) await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + return false + } + return true + } + + export async function remove(id: string): Promise { + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const remaining = entries.filter((entry) => entry.id !== id) + if (remaining.length !== entries.length) await write(remaining) + } + + /** Remove a normal-completion entry only after its exact process and every + * same-group descendant are gone. Background work is reaped before durable + * credential ownership can be dropped. */ + export async function complete(id: string): Promise { + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const entry = entries.find((item) => item.id === id) + if (!entry) return true + if (await owns(entry.pid, entry.identity)) return false + if (entry.detached || entry.windows_job) await teardownGroup(entry) + await write(entries.filter((item) => item.id !== id)) + return true + } + + async function killExactProcess(entry: Entry): Promise { + if (!(await owns(entry.pid, entry.identity))) return false + if (process.platform === "win32") { + const proc = Bun.spawn(["taskkill", "/pid", String(entry.pid), "/f", "/t"], { + env: processEnv(), + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }) + await proc.exited + } else { + try { + process.kill(entry.pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error + } + } + for (let attempt = 0; attempt < 100; attempt++) { + if (!(await owns(entry.pid, entry.identity))) return true + await Bun.sleep(20) + } + throw new Error(`Credential-bearing ${entry.kind} process ${entry.pid} did not exit`) + } + + async function teardown(entry: Entry, options: RevokeOptions = {}): Promise { + if (entry.detached || entry.windows_job) return teardownGroup(entry, options) + if (await owns(entry.pid, entry.identity)) await options.onPinned?.(entry.id) + return killExactProcess(entry) + } + + export function killExact(input: { + id: string + kind: Kind + pid: number + identity: string + detached: boolean + }): Promise { + return teardown({ + version: 1, + ...input, + ...(process.platform === "win32" ? { windows_job: undefined } : {}), + owner_pid: 0, + created_at: new Date(0).toISOString(), + }) + } + + /** Kill exact, identity-matched children even when their owner server died. */ + export async function revoke(scope?: Kind | Scope, options: RevokeOptions = {}): Promise { + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const retained: Entry[] = [] + let killed = 0 + const failures: unknown[] = [] + for (const entry of entries) { + const match = + typeof scope === "string" + ? entry.kind === scope + : (!scope?.id || entry.id === scope.id) && + (!scope?.kind || entry.kind === scope.kind) && + (!scope?.projectID || !entry.project_id || entry.project_id === scope.projectID) && + (!scope?.sessionID || !entry.session_id || entry.session_id === scope.sessionID) + if (!match) { + retained.push(entry) + continue + } + try { + if (await teardown(entry, options)) killed++ + } catch (error) { + retained.push(entry) + failures.push(error) + } + } + await write(retained) + if (failures.length) throw new AggregateError(failures, "Credential-bearing child revocation failed") + return killed + } + + export function pathForTests(): string { + return filepath + } +} diff --git a/backend/cli/src/file/index.ts b/backend/cli/src/file/index.ts index dd4f8285..be7e0dba 100644 --- a/backend/cli/src/file/index.ts +++ b/backend/cli/src/file/index.ts @@ -2,7 +2,6 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import z from "zod" import { $ } from "bun" -import type { BunFile } from "bun" import { formatPatch, structuredPatch } from "diff" import { HTTPException } from "hono/http-exception" import path from "path" @@ -21,6 +20,7 @@ import { PublicationFile } from "./publication" import { PublicationReview } from "./review" import { SessionFilesystem } from "../session/filesystem" import { Filesystem } from "../util/filesystem" +import { SafeFileIO } from "./safe-io" export namespace File { const log = Log.create({ service: "file" }) @@ -88,7 +88,7 @@ export namespace File { }) export type Content = z.infer - async function shouldEncode(file: BunFile): Promise { + async function shouldEncode(file: { type?: string }): Promise { const type = file.type?.toLowerCase() log.info("shouldEncode", { type }) if (!type) return false @@ -317,11 +317,11 @@ export namespace File { using _ = log.time("read", { file }) const project = Instance.project - const bunFile = Bun.file(full) - - if (!(await bunFile.exists())) { + const snapshot = await SafeFileIO.optional(full) + if (!snapshot) { return { type: "text", content: "" } } + const bunFile = new Blob([new Uint8Array(snapshot.bytes)], { type: Bun.file(full).type }) const encode = ScienceFile.binary(file) || (await shouldEncode(bunFile)) @@ -379,14 +379,14 @@ export namespace File { export async function inspect(file: string, options?: AccessOptions): Promise { const full = await contained(file, "read", options) - return ScienceFile.inspect(full, file) + return ScienceFile.inspect(full, file, options) } - export async function raw(file: string, options?: AccessOptions): Promise { + export async function raw(file: string, options?: AccessOptions): Promise { const full = await contained(file, "read", options) - const content = Bun.file(full) - if (!(await content.exists())) throw new HTTPException(404, { message: `File not found: ${file}` }) - return content + const snapshot = await SafeFileIO.optional(full) + if (!snapshot) throw new HTTPException(404, { message: `File not found: ${file}` }) + return new Blob([new Uint8Array(snapshot.bytes)], { type: Bun.file(full).type }) } export async function artifacts(options?: AccessOptions): Promise { @@ -446,8 +446,9 @@ export namespace File { using _ = log.time("write", { file }) const full = await contained(file, "write", options) - const exists = await Bun.file(full).exists() - await Bun.write(full, content) + const approved = await SafeFileIO.optional(full) + const exists = !!approved + await SafeFileIO.write(full, content, approved) await Bus.publish(File.Event.Edited, { file: full, }) diff --git a/backend/cli/src/file/publication.ts b/backend/cli/src/file/publication.ts index b25b21a6..1e0493d9 100644 --- a/backend/cli/src/file/publication.ts +++ b/backend/cli/src/file/publication.ts @@ -1,11 +1,22 @@ import fs from "node:fs/promises" +import os from "node:os" import path from "node:path" +import { spawn, type ChildProcess } from "node:child_process" import { marked, Renderer } from "marked" import z from "zod" +import { Config } from "../config/config" import { OpenScience } from "../openscience" +import { AuthoritySignal } from "../project/authority-signal" +import { Instance } from "../project/instance" +import { ProjectTrust } from "../project/trust" +import { Sandbox } from "../sandbox/sandbox" +import { CommandRuntime } from "../science/command/registry" +import { Shell } from "../shell/shell" import { Filesystem } from "../util/filesystem" import { escapeHtml } from "../util/html" import { PublicationReview } from "./review" +import { SafeFileIO } from "./safe-io" +import { WindowsJobLauncher } from "../process/windows-job-launcher" export namespace PublicationFile { export const Format = z.enum(["html", "pdf", "docx", "latex", "pptx"]) @@ -54,6 +65,9 @@ export namespace PublicationFile { pptx: "pptx", } + const exportTimeoutMs = 120_000 + const diagnosticLimit = 64 * 1024 + export async function capabilities(): Promise { const options = { PATH: process.env.PATH } const pandoc = Boolean(Bun.which("pandoc", options)) @@ -97,8 +111,18 @@ export namespace PublicationFile { : `${parsed.format.toUpperCase()} export requires Pandoc`, ) } + // Tool-backed publication runs project-controlled Markdown, TeX and local + // resource bytes through host executables. Do not create even the export + // directory until the user has explicitly trusted that project. + if (parsed.format !== "html") { + const canonicalRoot = await Filesystem.canonical(root) + if (canonicalRoot !== Instance.directory) { + throw new Error("Publication export project does not match the active project") + } + await ProjectTrust.require(Instance.project, "publication_export") + } + const folder = path.join(root, "exports") - await fs.mkdir(folder, { recursive: true }) const stamp = new Date().toISOString().replace(/\D/g, "").slice(0, 17) const nonce = crypto.randomUUID().slice(0, 8) const stem = @@ -166,56 +190,244 @@ export namespace PublicationFile { ${body} - + ` - await Bun.write(target, document) - const stat = await fs.stat(target) + await SafeFileIO.write(target, document) return Result.parse({ path: relative.split(path.sep).join("/"), format: parsed.format, - size: stat.size, + size: Buffer.byteLength(document), created_at: new Date().toISOString(), engine: "OpenScience Markdown", readiness: parsed.readiness, ...(review ? { review_id: review.id } : {}), }) } - const snapshotFile = path.join(folder, `.openscience-publication-${nonce}.md`) - await Bun.write(snapshotFile, snapshot) - const args = [ - "pandoc", - snapshotFile, - "--standalone", - `--resource-path=${path.dirname(source)}${path.delimiter}${root}`, - "--output", - target, - ...(parsed.format === "pdf" && support.pdf_engine ? [`--pdf-engine=${support.pdf_engine}`] : []), - ] - const proc = Bun.spawn(args, { - cwd: root, - env: await OpenScience.subprocessEnv(process.env), - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + // Keep both the immutable input snapshot and untrusted converter output in + // a private, one-run directory. The sandbox sees the project read-only and + // can write only here; host-side SafeFileIO performs the final no-follow, + // no-overwrite install into exports after the child exits successfully. + const job = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-publication-")) + const snapshotFile = path.join(job, "source.md") + const generatedFile = path.join(job, `result.${extensions[parsed.format]}`) + let lifecycle: + | { + child: ChildProcess + sandbox: ReturnType + closed: boolean + } + | undefined + let releaseRequested = false + let releasePromise: Promise | undefined + const release = () => + (releasePromise ??= Promise.resolve().then(async () => { + if (lifecycle) Sandbox.cleanup(lifecycle.sandbox) + await fs.rm(job, { recursive: true, force: true }) + })) + const requestRelease = async () => { + releaseRequested = true + if (!lifecycle || lifecycle.closed || stopped(lifecycle.child)) await release() + } + + try { + await fs.chmod(job, 0o700) + await fs.writeFile(snapshotFile, Buffer.from(snapshot), { flag: "wx", mode: 0o600 }) + const launched = await AuthoritySignal.exclusive(async () => { + // This final check shares the same interprocess lease as trust + // revocation. Once spawn wins, the child is durably registered before + // revocation can be acknowledged; if revocation wins, no child starts. + await ProjectTrust.require(Instance.project, "publication_export") + const toolPath = process.env.PATH + const pandoc = Bun.which("pandoc", { PATH: toolPath }) + const pdfEngine = + parsed.format === "pdf" + ? (Bun.which("xelatex", { PATH: toolPath }) ?? + Bun.which("pdflatex", { PATH: toolPath }) ?? + Bun.which("typst", { PATH: toolPath })) + : undefined + if (!pandoc) throw new Error(`${parsed.format.toUpperCase()} export requires Pandoc`) + if (parsed.format === "pdf" && !pdfEngine) { + throw new Error("PDF export requires Pandoc and a local TeX or Typst engine") + } + + const args = [ + snapshotFile, + "--standalone", + `--resource-path=${path.dirname(source)}${path.delimiter}${root}`, + "--output", + generatedFile, + ...(pdfEngine ? [`--pdf-engine=${pdfEngine}`] : []), + ] + const options = await Config.trustedSandbox() + const sandbox = Sandbox.wrapArgv({ + file: pandoc, + args, + // Publication converters only need to read the manuscript and its + // resources. They never receive write authority to the project. + workspace: [], + readable: [root], + extraWritable: [job], + unreadable: OpenScience.kernelSensitivePaths(), + options, + }) + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + const detached = process.platform !== "win32" + let child: ChildProcess + try { + child = spawn(wrapped.file, wrapped.args, { + cwd: root, + env: { + ...OpenScience.kernelEnv(process.env), + HOME: job, + XDG_CACHE_HOME: path.join(job, "cache"), + XDG_CONFIG_HOME: path.join(job, "config"), + XDG_DATA_HOME: path.join(job, "data"), + }, + stdio: ["ignore", "pipe", "pipe"], + detached, + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + + const output = completion(child) + const stop = () => Shell.killTree(child, { exited: () => stopped(child), detached }) + const registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: "publication", + messageID: "publication", + description: `Export ${path.basename(source)} as ${parsed.format.toUpperCase()}`, + command: `pandoc ${parsed.format} export`, + }, + child, + stop, + { windowsRelease: wrapped.release }, + ).catch(async (error) => { + void output.catch(() => undefined) + if (!stopped(child)) await stop() + Sandbox.cleanup(sandbox) + throw error + }) + return { child, output, registered, sandbox, stop, pdfEngine } + }) + lifecycle = { + child: launched.child, + sandbox: launched.sandbox, + closed: stopped(launched.child), + } + const closed = () => { + if (!lifecycle) return + lifecycle.closed = true + if (releaseRequested) void release() + } + launched.child.once("close", closed) + launched.child.once("error", closed) + if (stopped(launched.child)) lifecycle.closed = true + + const timeout = timeoutAfter(launched.child, launched.stop) + let result: Awaited> + try { + result = await Promise.race([launched.output, timeout.promise]) + } finally { + timeout.cancel() + } + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `Pandoc exited with code ${result.code}`) + } + + // Serialize the final artifact acceptance with trust mutation as well. + // A converter result cannot be acknowledged after the project has been + // revoked while it was running. + const size = await AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "publication_export") + const generated = await SafeFileIO.read(generatedFile) + await SafeFileIO.write(target, generated.bytes) + return generated.bytes.length + }) + return Result.parse({ + path: relative.split(path.sep).join("/"), + format: parsed.format, + size, + created_at: new Date().toISOString(), + engine: parsed.format === "pdf" ? `pandoc + ${path.basename(launched.pdfEngine!)}` : "pandoc", + readiness: parsed.readiness, + ...(review ? { review_id: review.id } : {}), + }) + } finally { + // A child that somehow survives forced termination stays registered and + // retains its sandbox/job directory for later durable reaping. Releasing + // those paths while it is still alive would turn a timeout into an + // authority escape. Normal exits clean synchronously here. + await requestRelease() + } + } + + function stopped(child: ChildProcess) { + return child.exitCode !== null || child.signalCode !== null + } + + function completion(child: ChildProcess) { + let stdout = "" + let stderr = "" + child.stdout?.on("data", (chunk) => { + if (stdout.length < diagnosticLimit) stdout += String(chunk).slice(0, diagnosticLimit - stdout.length) + }) + child.stderr?.on("data", (chunk) => { + if (stderr.length < diagnosticLimit) stderr += String(chunk).slice(0, diagnosticLimit - stderr.length) }) - const [code, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]).finally(() => fs.rm(snapshotFile, { force: true })) - if (code !== 0) { - await fs.rm(target, { force: true }) - throw new Error(stderr.trim() || stdout.trim() || `Pandoc exited with code ${code}`) + return new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr })) + }) + } + + function timeoutAfter(child: ChildProcess, stop: () => Promise) { + let timer: ReturnType | undefined + const promise = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + void stop() + .then(() => waitForStop(child)) + .then( + () => reject(new Error(`Pandoc timed out after ${Math.round(exportTimeoutMs / 1_000)} seconds`)), + (error) => reject(new AggregateError([error], "Pandoc timed out and could not be stopped")), + ) + }, exportTimeoutMs) + timer.unref() + }) + return { + promise, + cancel() { + if (timer) clearTimeout(timer) + }, } - const stat = await fs.stat(target) - return Result.parse({ - path: relative.split(path.sep).join("/"), - format: parsed.format, - size: stat.size, - created_at: new Date().toISOString(), - engine: parsed.format === "pdf" ? `pandoc + ${support.pdf_engine}` : "pandoc", - readiness: parsed.readiness, - ...(review ? { review_id: review.id } : {}), + } + + async function waitForStop(child: ChildProcess) { + if (stopped(child)) return + await new Promise((resolve, reject) => { + const finish = () => { + clearTimeout(timer) + child.off("close", finish) + child.off("error", fail) + resolve() + } + const fail = (error: Error) => { + clearTimeout(timer) + child.off("close", finish) + child.off("error", fail) + reject(error) + } + const timer = setTimeout(() => { + child.off("close", finish) + child.off("error", fail) + reject(new Error("Pandoc remained alive after forced termination")) + }, 2_000) + timer.unref() + child.once("close", finish) + child.once("error", fail) + if (stopped(child)) finish() }) } diff --git a/backend/cli/src/file/ripgrep.ts b/backend/cli/src/file/ripgrep.ts index 6a94fbf9..b1fa1e41 100644 --- a/backend/cli/src/file/ripgrep.ts +++ b/backend/cli/src/file/ripgrep.ts @@ -5,7 +5,6 @@ import fs from "fs/promises" import z from "zod" import { NamedError } from "@synsci/util/error" import { lazy } from "../util/lazy" -import { $ } from "bun" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { Log } from "@/util/log" @@ -380,7 +379,8 @@ export namespace Ripgrep { limit?: number follow?: boolean }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + const executable = await filepath() + const args = ["--json", "--hidden", "--glob=!.git/*"] if (input.follow !== false) args.push("--follow") if (input.glob) { @@ -396,14 +396,28 @@ export namespace Ripgrep { args.push("--") args.push(input.pattern) - const command = args.join(" ") - const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() - if (result.exitCode !== 0) { + // The pattern is untrusted HTTP input. Keep it as one argv element after + // `--`; constructing a shell command here turns newlines, substitutions, + // and metacharacters into host command execution before project trust. + const proc = Bun.spawn([executable, ...args], { + cwd: input.cwd, + env: Object.fromEntries( + ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "SYSTEMROOT", "WINDIR", "TEMP", "TMP"].flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]!]], + ), + ), + stdout: "pipe", + stderr: "ignore", + maxBuffer: 1024 * 1024 * 20, + }) + const [exitCode, output] = await Promise.all([proc.exited, Bun.readableStreamToText(proc.stdout)]) + // ripgrep uses 1 for a valid search with no matches. + if (exitCode !== 0 && exitCode !== 1) { return [] } // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = result.text().trim().split(/\r?\n/).filter(Boolean) + const lines = output.trim().split(/\r?\n/).filter(Boolean) // Parse JSON lines from ripgrep output return lines diff --git a/backend/cli/src/file/safe-io.ts b/backend/cli/src/file/safe-io.ts new file mode 100644 index 00000000..9c1b451b --- /dev/null +++ b/backend/cli/src/file/safe-io.ts @@ -0,0 +1,127 @@ +import crypto from "node:crypto" +import { constants as FS } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { Filesystem } from "@/util/filesystem" + +/** Final-component symlink-safe file I/O for host broker operations. */ +export namespace SafeFileIO { + export type Snapshot = { + bytes: Buffer + dev: number + ino: number + mode: number + mtimeMs: number + } + + export async function read(filepath: string): Promise { + const requested = await fs.lstat(filepath) + if (requested.isSymbolicLink()) throw new Error(`Refusing to follow a symbolic link: ${filepath}`) + const handle = await fs.open(filepath, FS.O_RDONLY | FS.O_NOFOLLOW) + try { + const before = await handle.stat() + if (!before.isFile()) throw new Error(`Only regular files can be accessed: ${filepath}`) + const bytes = await handle.readFile() + const after = await handle.stat() + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs + ) { + throw new Error(`Refusing to read ${filepath}: the file changed during access`) + } + return { bytes, dev: after.dev, ino: after.ino, mode: after.mode & 0o777, mtimeMs: after.mtimeMs } + } finally { + await handle.close() + } + } + + export async function optional(filepath: string) { + return read(filepath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + } + + export async function absent(filepath: string) { + const exists = await fs.lstat(filepath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false + throw error + }, + ) + if (exists) throw new Error(`Refusing to overwrite an unapproved file: ${filepath}`) + } + + export async function assert(filepath: string, approved: Snapshot) { + const current = await read(filepath) + if (current.dev !== approved.dev || current.ino !== approved.ino) { + throw new Error(`Refusing to write ${filepath}: the file identity changed after approval`) + } + if (!current.bytes.equals(approved.bytes)) { + throw new Error(`Refusing to write ${filepath}: the file changed after approval`) + } + } + + async function stage(target: string, content: string | Uint8Array, mode: number) { + await fs.mkdir(path.dirname(target), { recursive: true }) + const canonical = await Filesystem.canonical(target) + if (!canonical || canonical !== target) throw new Error(`Write destination became ambiguous: ${target}`) + const staged = path.join(path.dirname(target), `.openscience-write-${crypto.randomUUID()}.tmp`) + await fs.writeFile(staged, content, { flag: "wx", mode }) + return staged + } + + async function install(staged: string, target: string) { + try { + await fs.link(staged, target) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`Refusing to overwrite an unapproved file: ${target}`) + } + throw error + } + } + + export async function write(filepath: string, content: string | Uint8Array, approved?: Snapshot) { + if (!approved) { + await absent(filepath) + const staged = await stage(filepath, content, 0o644) + try { + await install(staged, filepath) + } finally { + await fs.rm(staged, { force: true }) + } + return + } + + await assert(filepath, approved) + const staged = await stage(filepath, content, approved.mode) + const backup = path.join(path.dirname(filepath), `.openscience-approved-${crypto.randomUUID()}.bak`) + let moved = false + let installed = false + try { + await fs.rename(filepath, backup) + moved = true + await assert(backup, approved) + await install(staged, filepath) + installed = true + await fs.unlink(staged) + await fs.unlink(backup) + } catch (error) { + if (moved && !installed) { + try { + await install(backup, filepath) + await fs.unlink(backup) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `Write failed; original retained at ${backup}`) + } + } + throw error + } finally { + await fs.rm(staged, { force: true }) + } + } +} diff --git a/backend/cli/src/file/science.ts b/backend/cli/src/file/science.ts index 65dbbbcb..09c6b297 100644 --- a/backend/cli/src/file/science.ts +++ b/backend/cli/src/file/science.ts @@ -1,7 +1,31 @@ import path from "node:path" +import fs from "node:fs/promises" +import os from "node:os" +import { spawn, type ChildProcess } from "node:child_process" import z from "zod" +import { Config } from "@/config/config" +import { CredentialProcessLedger } from "@/credentials/process-ledger" +import { OpenScience } from "@/openscience" +import { ProcessIdentity } from "@/process/process-identity" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" +import { AuthoritySignal } from "@/project/authority-signal" +import { ExecutionAuthority } from "@/project/execution" +import { Instance } from "@/project/instance" +import { ProjectTrust } from "@/project/trust" +import { Sandbox } from "@/sandbox/sandbox" +import { CommandRuntime } from "@/science/command/registry" +import { SessionFilesystem } from "@/session/filesystem" +import { Shell } from "@/shell/shell" export namespace ScienceFile { + const TOOL_TIMEOUT_MS = 20_000 + const MAX_STDOUT_BYTES = 8 * 1024 * 1024 + const MAX_STDERR_BYTES = 64 * 1024 + + export interface InspectOptions { + sessionID?: string + } + export const Format = z.enum(["bam", "cram", "h5ad", "loom"]) export type Format = z.infer @@ -159,7 +183,7 @@ print(json.dumps(result)) return format(file) !== undefined } - export async function inspect(full: string, relative: string): Promise { + export async function inspect(full: string, relative: string, options: InspectOptions = {}): Promise { const kind = format(relative) if (!kind) throw new Error(`Unsupported scientific binary format`) const file = Bun.file(full) @@ -172,17 +196,33 @@ print(json.dumps(result)) size: stat.size, modified: stat.mtimeMs, } - if (kind === "h5ad" || kind === "loom") return inspectHdf5(full, base, bytes) - return inspectAlignment(full, relative, base, bytes) + const trusted = await ProjectTrust.allowed(Instance.project) + if (kind === "h5ad" || kind === "loom") return inspectHdf5(full, relative, base, bytes, trusted, options) + return inspectAlignment(full, relative, base, bytes, trusted, options) } async function inspectHdf5( full: string, + relative: string, base: Pick, bytes: Uint8Array, + trusted: boolean, + options: InspectOptions, ): Promise { - const bin = Bun.which("python3") ?? Bun.which("python") const signature = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value) + if (!trusted) { + return { + ...base, + signature, + tool: { + name: "h5py", + available: false, + detail: "Trust this project to enable isolated h5py inspection", + }, + details: {}, + } + } + const bin = Bun.which("python3", { PATH: process.env.PATH }) ?? Bun.which("python", { PATH: process.env.PATH }) if (!bin) { return { ...base, @@ -191,7 +231,11 @@ print(json.dumps(result)) details: {}, } } - const result = await command([bin, "-c", python, full], 20_000) + const result = await command([bin, "-c", python, full], [full], relative, options).catch((error) => ({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) const data = result.code === 0 ? json(result.stdout) : undefined return { ...base, @@ -214,24 +258,44 @@ print(json.dumps(result)) relative: string, base: Pick, bytes: Uint8Array, + trusted: boolean, + options: InspectOptions, ): Promise { - const bin = Bun.which("samtools") const cram = base.format === "cram" const signature = cram ? bytes[0] === 0x43 && bytes[1] === 0x52 && bytes[2] === 0x41 && bytes[3] === 0x4d : bytes[0] === 0x1f && bytes[1] === 0x8b - const index = await findIndex(full, relative, cram) + const index = await findIndex(full, relative, cram, options) const version = cram && signature ? `${bytes[4] ?? 0}.${bytes[5] ?? 0}` : undefined + if (!trusted) { + return { + ...base, + signature, + index: index?.relative, + tool: { + name: "samtools", + available: false, + detail: "Trust this project to enable isolated samtools inspection", + }, + details: version ? { version } : {}, + } + } + const bin = Bun.which("samtools", { PATH: process.env.PATH }) if (!bin) { return { ...base, signature, - index, + index: index?.relative, tool: { name: "samtools", available: false, detail: "Install samtools to inspect headers and references" }, details: version ? { version } : {}, } } - const header = await command([bin, "view", "-H", full], 20_000) + const readable = [full, ...(index ? [index.full] : [])] + const header = await command([bin, "view", "-H", full], readable, relative, options).catch((error) => ({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) const refs = header.stdout .split(/\r?\n/) .filter((line) => line.startsWith("@SQ")) @@ -250,7 +314,13 @@ print(json.dumps(result)) ?.split("\t") .slice(1) .map((part) => part.split(":", 2)) - const stats = index ? await command([bin, "idxstats", full], 20_000) : undefined + const stats = index + ? await command([bin, "idxstats", full], readable, relative, options).catch((error) => ({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + : undefined const chromosomes = stats?.code === 0 ? stats.stdout @@ -268,7 +338,7 @@ print(json.dumps(result)) return { ...base, signature, - index, + index: index?.relative, tool: { name: "samtools", available: header.code === 0, @@ -283,31 +353,236 @@ print(json.dumps(result)) } } - async function findIndex(full: string, relative: string, cram: boolean): Promise { + async function findIndex( + full: string, + relative: string, + cram: boolean, + options: InspectOptions, + ): Promise<{ full: string; relative: string } | undefined> { const extension = cram ? ".crai" : ".bai" const candidates = [full + extension, full.replace(/\.[^.]+$/, extension)] const found = await Promise.all( - candidates.map(async (candidate) => ((await Bun.file(candidate).exists()) ? candidate : undefined)), + candidates.map(async (candidate) => { + if (!(await Bun.file(candidate).exists())) return + const canonical = await fs.realpath(candidate).catch(() => undefined) + if (!canonical) return + if (options.sessionID) { + const authorized = await SessionFilesystem.authorize({ + sessionID: options.sessionID, + path: canonical, + access: "read", + }).catch(() => undefined) + if (!authorized || path.resolve(authorized.path) !== path.resolve(canonical)) return + } else if (!(await Instance.containsCanonicalPath(canonical))) { + return + } + return canonical + }), ) const value = found.find(Boolean) if (!value) return - return path.join(path.dirname(relative), path.basename(value)).replace(/^\.\//, "") + return { + full: value, + relative: path.join(path.dirname(relative), path.basename(value)).replace(/^\.\//, ""), + } + } + + function environment(scratch: string): Record { + const keys = + process.platform === "win32" + ? ["PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP"] + : ["PATH", "LANG"] + const result = Object.fromEntries(keys.flatMap((key) => (process.env[key] ? [[key, process.env[key]!]] : []))) + for (const [key, value] of Object.entries(process.env)) { + if (value && key.startsWith("LC_")) result[key] = value + } + return { + ...result, + HOME: scratch, + TMPDIR: scratch, + TMP: scratch, + TEMP: scratch, + XDG_CACHE_HOME: path.join(scratch, "cache"), + XDG_CONFIG_HOME: path.join(scratch, "config"), + XDG_DATA_HOME: path.join(scratch, "data"), + PYTHONNOUSERSITE: "1", + PYTHONSAFEPATH: "1", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONUNBUFFERED: "1", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } } - async function command(args: string[], timeout: number): Promise<{ code: number; stdout: string; stderr: string }> { - const process = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }) - const timer = setTimeout(() => process.kill(), timeout) - const code = await process.exited - clearTimeout(timer) - const [stdout, stderr] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - ]) - return { code, stdout, stderr } + function output(stream: NodeJS.ReadableStream | null, limit: number, name: "stdout" | "stderr"): Promise { + if (!stream) return Promise.resolve("") + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let settled = false + const fail = (error: unknown) => { + if (settled) return + settled = true + reject(error) + } + stream.on("data", (value: Buffer | string) => { + if (settled) return + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + size += chunk.length + if (size > limit) { + fail(new Error(`Scientific preview ${name} exceeded ${limit} bytes`)) + return + } + chunks.push(chunk) + }) + stream.once("error", fail) + stream.once("end", () => { + if (settled) return + settled = true + resolve(Buffer.concat(chunks, size).toString("utf8")) + }) + }) + } + + async function stop(child: ChildProcess): Promise { + await Shell.killTree(child, { + detached: process.platform !== "win32", + exited: () => child.exitCode !== null || child.signalCode !== null, + }) + } + + async function command( + args: string[], + readable: string[], + relative: string, + options: InspectOptions, + ): Promise<{ code: number; stdout: string; stderr: string }> { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), `openscience-science-preview-${process.pid}-`)) + let sandbox: Sandbox.Wrapped | undefined + let child: ChildProcess | undefined + let registered: Awaited> | undefined + try { + const launched = await AuthoritySignal.exclusive(async () => { + const trust = await ProjectTrust.status(Instance.project) + if (!trust.canExecuteProjectCode) throw new Error("Project trust was revoked before scientific inspection") + + let generation = `science-preview:${trust.revision}` + let policy = await Config.trustedSandbox() + if (options.sessionID) { + const decision = await ExecutionAuthority.decide({ + projectID: Instance.project.id, + sessionID: options.sessionID, + capability: "kernel", + }) + if (!decision.allowed) throw new ExecutionAuthority.DeniedError(decision) + const authorized = await SessionFilesystem.authorize({ + sessionID: options.sessionID, + path: relative, + access: "read", + }) + if (path.resolve(authorized.path) !== path.resolve(readable[0]!)) { + throw new Error("Scientific preview authority changed while the file was being inspected") + } + generation = decision.generation + policy = decision.sandbox + } else if (!(await Instance.containsCanonicalPath(readable[0]!))) { + throw new Error("Scientific preview target left the trusted project") + } + + sandbox = Sandbox.wrapArgv({ + file: args[0]!, + args: args.slice(1), + workspace: [], + readable, + extraWritable: [scratch], + unreadable: OpenScience.kernelSensitivePaths(), + options: { ...policy, network: "deny" }, + }) + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + throw new Error("Could not capture the Linux server identity for scientific preview") + } + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args, linuxOwner }) + child = spawn(wrapped.file, wrapped.args, { + cwd: scratch, + env: environment(scratch), + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: true, + }) + const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child!.once("error", reject) + child!.once("close", (code, signal) => resolve({ code, signal })) + }) + registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: options.sessionID ?? "file-preview", + messageID: "file.inspect", + description: `Inspect ${path.basename(relative)}`, + command: `${path.basename(args[0]!)} scientific preview`, + }, + child, + () => stop(child!), + { authorityGeneration: generation, windowsRelease: wrapped.release }, + ) + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid!) + } + return { completion, child, registered } + }) + + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Scientific preview timed out after 20 seconds")), TOOL_TIMEOUT_MS) + timer.unref() + }) + const [result, stdout, stderr] = await Promise.race([ + Promise.all([ + launched.completion, + output(launched.child.stdout, MAX_STDOUT_BYTES, "stdout"), + output(launched.child.stderr, MAX_STDERR_BYTES, "stderr"), + ]), + timeout, + ]).finally(() => clearTimeout(timer)) + await CredentialProcessLedger.complete(launched.registered.id) + CommandRuntime.finish(launched.registered.id) + return { code: result.code ?? 1, stdout, stderr } + } catch (error) { + const failures: unknown[] = [] + if (registered) { + await CredentialProcessLedger.revoke({ id: registered.id, kind: "command" }).catch((failure) => + failures.push(failure), + ) + if (!failures.length) CommandRuntime.finish(registered.id) + } + if (child && child.exitCode === null && child.signalCode === null) { + await stop(child).catch((failure) => failures.push(failure)) + } + if (failures.length) { + throw new AggregateError([error, ...failures], "Scientific preview ownership cleanup failed") + } + throw error + } finally { + if (sandbox) Sandbox.cleanup(sandbox) + await fs.rm(scratch, { recursive: true, force: true }) + } } function json(value: string): Record | undefined { - return JSON.parse(value) as Record + try { + const parsed: unknown = JSON.parse(value) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return + return parsed as Record + } catch { + return + } } function detail(stdout: string, stderr: string): string { diff --git a/backend/cli/src/file/trash.ts b/backend/cli/src/file/trash.ts new file mode 100644 index 00000000..3a724c58 --- /dev/null +++ b/backend/cli/src/file/trash.ts @@ -0,0 +1,257 @@ +import crypto from "node:crypto" +import path from "node:path" +import fs from "node:fs/promises" +import { constants as FS } from "node:fs" +import z from "zod" +import { Global } from "@/global" +import { SessionFilesystem } from "@/session/filesystem" +import { Lock } from "@/util/lock" +import { Filesystem } from "@/util/filesystem" + +/** Recoverable trash for source/workspace files deleted by agent edit tools. + * Bytes live outside the project so a later project command cannot mutate the + * recovery copy. Records expire after 30 days and are purged opportunistically. */ +export namespace FileTrash { + export const RETENTION_MS = 30 * 24 * 60 * 60 * 1000 + + export const Record = z.object({ + id: z.string().startsWith("ftr_"), + projectID: z.string(), + sessionID: z.string().optional(), + originalPath: z.string(), + filename: z.string(), + size: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + mode: z.number().int().nonnegative(), + state: z.enum(["trash", "restored"]), + trashedAt: z.number().int().positive(), + expiresAt: z.number().int().positive(), + restoredAt: z.number().int().positive().optional(), + }) + export type Record = z.infer + + const root = path.join(Global.Path.data, "file-trash") + const segment = (value: string) => crypto.createHash("sha256").update(value).digest("hex") + const projectRoot = (projectID: string) => path.join(root, segment(projectID)) + const entryRoot = (projectID: string, id: string) => path.join(projectRoot(projectID), id) + const metadata = (projectID: string, id: string) => path.join(entryRoot(projectID, id), "record.json") + const payload = (projectID: string, id: string) => path.join(entryRoot(projectID, id), "payload") + const lock = (projectID: string) => `file-trash:${segment(projectID)}` + + async function writeRecord(record: Record) { + const target = metadata(record.projectID, record.id) + const temp = `${target}.${crypto.randomUUID()}.tmp` + await fs.writeFile(temp, JSON.stringify(record, null, 2), { mode: 0o600 }) + await fs.rename(temp, target) + } + + async function read(projectID: string, id: string) { + if (!/^ftr_[0-9a-f-]{36}$/.test(id)) return + return Bun.file(metadata(projectID, id)) + .json() + .then((value) => Record.parse(value)) + .catch(() => undefined) + } + + async function records(projectID: string) { + const names = await fs.readdir(projectRoot(projectID)).catch(() => [] as string[]) + const parsed = (await Promise.all(names.map((id) => read(projectID, id)))).filter( + (value): value is Record => !!value && value.projectID === projectID, + ) + // A crash after metadata is persisted but before the source inode is moved + // must not advertise a recovery record whose payload never existed. + const available = await Promise.all( + parsed.map(async (record) => { + const stat = await fs.lstat(payload(projectID, record.id)).catch(() => undefined) + return stat?.isFile() && !stat.isSymbolicLink() ? record : undefined + }), + ) + return available.filter((value): value is Record => !!value).toSorted((a, b) => b.trashedAt - a.trashedAt) + } + + async function purgeExpiredUnlocked(projectID: string, now = Date.now()) { + const expired = (await records(projectID)).filter((record) => record.expiresAt <= now) + await Promise.all(expired.map((record) => fs.rm(entryRoot(projectID, record.id), { recursive: true, force: true }))) + return expired.length + } + + export async function list(projectID: string) { + using _ = await Lock.write(lock(projectID)) + await purgeExpiredUnlocked(projectID) + return (await records(projectID)).filter((record) => record.state === "trash") + } + + async function openRegular(filepath: string) { + const handle = await fs.open(filepath, FS.O_RDONLY | FS.O_NOFOLLOW) + try { + const stat = await handle.stat() + if (!stat.isFile()) throw new Error(`Only canonical regular files can be trashed: ${filepath}`) + return { stat, content: await handle.readFile() } + } finally { + await handle.close() + } + } + + async function restoreMovedPayload(record: Record, removeEntry: boolean) { + const source = payload(record.projectID, record.id) + await fs.mkdir(path.dirname(record.originalPath), { recursive: true }) + await fs.chmod(source, record.mode) + try { + // Hard-link installation is exclusive: unlike rename(), it cannot + // overwrite a file that appeared at the restore path after approval. + await fs.link(source, record.originalPath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === "EEXIST") { + throw new Error(`Refusing to overwrite ${record.originalPath}; recovery payload retained at ${source}`) + } + throw new Error(`Could not restore ${record.originalPath}; recovery payload retained at ${source}: ${error}`) + } + if (!removeEntry) return + await fs.unlink(source) + await fs.rm(entryRoot(record.projectID, record.id), { recursive: true, force: true }) + } + + export async function trash(input: { + projectID: string + sessionID?: string + path: string + expectedContent?: string | Uint8Array + now?: number + }): Promise { + const requested = path.resolve(input.path) + const requestedStat = await fs.lstat(requested) + if (requestedStat.isSymbolicLink()) throw new Error(`Refusing to trash a symbolic link: ${requested}`) + const canonical = await Filesystem.canonical(input.path) + if (!canonical) throw new Error(`Cannot trash an ambiguous path: ${input.path}`) + const { stat, content } = await openRegular(canonical) + if (input.expectedContent !== undefined) { + const expected = + typeof input.expectedContent === "string" ? Buffer.from(input.expectedContent, "utf8") : input.expectedContent + if (!Buffer.from(expected).equals(content)) { + throw new Error(`Refusing to delete ${canonical}: the file changed after approval`) + } + } + + const id = `ftr_${crypto.randomUUID()}` + const now = input.now ?? Date.now() + const record = Record.parse({ + id, + projectID: input.projectID, + sessionID: input.sessionID, + originalPath: canonical, + filename: path.basename(canonical), + size: content.byteLength, + sha256: crypto.createHash("sha256").update(content).digest("hex"), + mode: stat.mode & 0o777, + state: "trash", + trashedAt: now, + expiresAt: now + RETENTION_MS, + }) + + using _ = await Lock.write(lock(input.projectID)) + await purgeExpiredUnlocked(input.projectID, now) + const directory = entryRoot(input.projectID, id) + await fs.mkdir(projectRoot(input.projectID), { recursive: true, mode: 0o700 }) + await fs.mkdir(directory, { recursive: false, mode: 0o700 }) + let moved = false + try { + // Persist recovery metadata before moving the inode. The project lock + // keeps the transient record private from list/restore calls, and a + // crash after rename still leaves a discoverable recovery record. + await writeRecord(record) + try { + // Same-filesystem rename is the deletion primitive. It atomically + // removes the pathname and preserves the exact inode; there is no + // lstat/read/unlink pathname race. + await fs.rename(canonical, payload(input.projectID, id)) + moved = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EXDEV") { + throw new Error( + `Recoverable deletion requires the trash and ${canonical} to share a filesystem; refusing to delete`, + ) + } + throw error + } + + const movedFile = await openRegular(payload(input.projectID, id)) + if (movedFile.stat.dev !== stat.dev || movedFile.stat.ino !== stat.ino) { + throw new Error(`Refusing to delete ${canonical}: the file identity changed after approval`) + } + if (!movedFile.content.equals(content)) { + throw new Error(`Refusing to delete ${canonical}: the file changed after approval`) + } + await fs.chmod(payload(input.projectID, id), 0o600) + return record + } catch (error) { + if (moved) { + try { + await restoreMovedPayload(record, true) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Trash operation failed; recovery payload retained for ${canonical}`, + ) + } + } else { + await fs.rm(directory, { recursive: true, force: true }) + } + throw error + } + } + + export async function restore(input: { projectID: string; sessionID: string; id: string }) { + using _ = await Lock.write(lock(input.projectID)) + const record = await read(input.projectID, input.id) + if (!record || record.state !== "trash") return + if (record.expiresAt <= Date.now()) { + await fs.rm(entryRoot(input.projectID, input.id), { recursive: true, force: true }) + return + } + const authorized = await SessionFilesystem.authorize({ + sessionID: input.sessionID, + path: record.originalPath, + access: "write", + }) + if (authorized.path !== record.originalPath) throw new Error("Trash restore path changed after authorization") + await fs.mkdir(path.dirname(record.originalPath), { recursive: true }) + const temp = path.join(path.dirname(record.originalPath), `.openscience-restore-${record.id}.tmp`) + try { + await fs.copyFile(payload(input.projectID, input.id), temp, FS.COPYFILE_EXCL) + await fs.chmod(temp, record.mode) + const restored = await fs.readFile(temp) + const digest = crypto.createHash("sha256").update(restored).digest("hex") + if (digest !== record.sha256) throw new Error(`Trash payload checksum mismatch for ${record.id}`) + try { + await fs.link(temp, record.originalPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`Refusing to overwrite an existing file while restoring ${record.originalPath}`) + } + throw error + } + } finally { + await fs.rm(temp, { force: true }) + } + const result = Record.parse({ ...record, state: "restored", restoredAt: Date.now() }) + await writeRecord(result) + return result + } + + /** Roll back a just-created trash record when a larger single-file edit + * cannot complete. This is intentionally not exposed through the server. */ + export async function rollback(record: Record) { + using _ = await Lock.write(lock(record.projectID)) + const stored = await read(record.projectID, record.id) + if (!stored || stored.state !== "trash" || stored.originalPath !== record.originalPath) { + throw new Error(`Cannot roll back unknown trash record ${record.id}`) + } + await restoreMovedPayload(stored, true) + } + + export async function purgeExpired(projectID: string, now = Date.now()) { + using _ = await Lock.write(lock(projectID)) + return purgeExpiredUnlocked(projectID, now) + } +} diff --git a/backend/cli/src/format/index.ts b/backend/cli/src/format/index.ts index 174a61a6..bbb6e50b 100644 --- a/backend/cli/src/format/index.ts +++ b/backend/cli/src/format/index.ts @@ -10,6 +10,12 @@ import { mergeDeep } from "remeda" import { Instance } from "../project/instance" import { OpenScience } from "@/openscience" import { ProjectTrust } from "@/project/trust" +import { AuthoritySignal } from "@/project/authority-signal" +import { Sandbox } from "@/sandbox/sandbox" +import { CommandRuntime } from "@/science/command/registry" +import { Shell } from "@/shell/shell" +import { spawn } from "node:child_process" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" export namespace Format { const log = Log.create({ service: "format" }) @@ -28,7 +34,6 @@ export namespace Format { const state = Instance.state(async () => { const enabled: Record = {} const cfg = await Config.getExecution() - const project = new Set() const formatters: Record = {} if (cfg.formatter === false) { @@ -36,7 +41,6 @@ export namespace Format { return { enabled, formatters, - project, } } @@ -44,7 +48,6 @@ export namespace Format { formatters[item.name] = item } for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (await Config.projectControls("formatter", name)) project.add(name) if (item.disabled) { delete formatters[name] continue @@ -65,7 +68,6 @@ export namespace Format { return { enabled, formatters, - project, } }) @@ -95,6 +97,66 @@ export namespace Format { return result } + async function run(item: Formatter.Info, file: string): Promise { + const command = item.command.map((value) => value.replace("$FILE", file)) + const launched = await AuthoritySignal.exclusive(async () => { + // Global binaries can still execute project-owned config, plugins, or + // hooks merely by starting in the project root. Binary location is not a + // safe trust boundary, so every formatter spawn requires project trust. + await ProjectTrust.require(Instance.project, "project_formatter") + const options = await Config.trustedSandbox() + const sandbox = Sandbox.wrapArgv({ + file: command[0]!, + args: command.slice(1), + workspace: [Instance.directory, Instance.worktree], + readable: [Instance.directory, Instance.worktree], + unreadable: OpenScience.kernelSensitivePaths(), + options, + }) + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + const child = (() => { + try { + return spawn(wrapped.file, wrapped.args, { + cwd: Instance.directory, + env: { ...OpenScience.kernelEnv(process.env), ...item.environment }, + stdio: "ignore", + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + })() + const exited = new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", (code) => resolve(code ?? 1)) + }) + const stop = () => + Shell.killTree(child, { exited: () => child.exitCode !== null, detached: process.platform !== "win32" }) + const registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: "formatter", + messageID: "formatter", + description: `Format ${path.basename(file)}`, + command: command.join(" "), + }, + child, + stop, + { windowsRelease: wrapped.release }, + ).catch(async (error) => { + if (child.exitCode === null && child.signalCode === null) await stop() + Sandbox.cleanup(sandbox) + throw error + }) + return { exited, registered, sandbox } + }) + return launched.exited.finally(() => { + CommandRuntime.finish(launched.registered.id) + Sandbox.cleanup(launched.sandbox) + }) + } + export async function status() { const s = await state() const result: Status[] = [] @@ -115,31 +177,11 @@ export namespace Format { const file = payload.properties.file log.info("formatting", { file }) const ext = path.extname(file) - const s = await state() for (const item of await getFormatter(ext)) { log.info("running", { command: item.command }) try { - const env = { ...(await OpenScience.subprocessEnv(process.env)), ...item.environment } - const command = item.command[0] - const target = path.isAbsolute(command) - ? command - : command.includes("/") || command.includes("\\") - ? path.resolve(Instance.directory, command) - : Bun.which(command, { PATH: env.PATH }) - const local = - target !== null && (Instance.containsPath(target) || (await Instance.containsCanonicalPath(target))) - if (item.project || s.project.has(item.name) || local) { - await ProjectTrust.require(Instance.project, "project_formatter") - } - const proc = Bun.spawn({ - cmd: item.command.map((x) => x.replace("$FILE", file)), - cwd: Instance.directory, - env, - stdout: "ignore", - stderr: "ignore", - }) - const exit = await proc.exited + const exit = await run(item, file) if (exit !== 0) log.error("failed", { command: item.command, diff --git a/backend/cli/src/global/data-relocation.ts b/backend/cli/src/global/data-relocation.ts new file mode 100644 index 00000000..cb212a48 --- /dev/null +++ b/backend/cli/src/global/data-relocation.ts @@ -0,0 +1,292 @@ +import { Database } from "bun:sqlite" +import { createHash, randomUUID } from "node:crypto" +import { createReadStream } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "@/global" +import { MemoryIndex } from "@/settings/memory-index" +import { DataRoot } from "./data-root" +import { DataRootBarrier } from "./data-root-barrier" + +export namespace DataRelocation { + export interface Result { + source: string + target: string + files: number + bytes: number + backup?: string + warning?: string + } + + const pointer = () => path.join(Global.Path.config, "data-location") + const transient = /(?:\.lock|\.tmp|\.partial|\.next|\.dead)$/ + // These roots contain OpenScience-owned metadata and process state. A + // suffix match is safe only inside them: workspaces, managed projects, and + // worktrees can contain ordinary user files named bun.lock, uv.lock, + // report.partial, or database journals that must move with their database. + const transientRoots = new Set([ + "artifact-store", + "authority", + "compute", + "file-trash", + "kernel-registry", + "local-runtime", + "log", + "migrations", + "project-leases", + "provenance", + "runtime", + "session-delete", + "settings", + "storage", + "trace", + ]) + const skipped = new Set([ + path.join("artifact-store", "artifacts.db-wal"), + path.join("artifact-store", "artifacts.db-shm"), + path.join("settings", "memory", "index.db"), + path.join("settings", "memory", "index.db-wal"), + path.join("settings", "memory", "index.db-shm"), + ]) + + function appTransient(relative: string, name: string) { + if (!transient.test(name)) return false + const [root, ...rest] = relative.split(path.sep) + return rest.length === 0 || transientRoots.has(root!) + } + + function inside(parent: string, candidate: string) { + const relative = path.relative(parent, candidate) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) + } + + function safeRelative(value: string) { + return !path.isAbsolute(value) && !path.normalize(value).split(/[\\/]/).includes("..") + } + + async function hash(filepath: string) { + const digest = createHash("sha256") + for await (const chunk of createReadStream(filepath)) digest.update(chunk) + return digest.digest("hex") + } + + async function atomicWrite(filepath: string, content: string) { + const temporary = `${filepath}.${process.pid}.${randomUUID()}.tmp` + const handle = await fs.open(temporary, "wx", 0o600) + try { + await handle.writeFile(content) + await handle.sync() + await handle.close() + await fs.rename(temporary, filepath) + } catch (error) { + await handle.close().catch(() => undefined) + await fs.rm(temporary, { force: true }).catch(() => undefined) + throw error + } + } + + function sqliteString(value: string) { + return `'${value.replaceAll("'", "''")}'` + } + + async function snapshotDatabase(source: string, destination: string) { + await fs.mkdir(path.dirname(destination), { recursive: true }) + const db = new Database(source, { readonly: true }) + try { + db.exec(`VACUUM INTO ${sqliteString(destination)}`) + } finally { + db.close() + } + const copied = new Database(destination, { readonly: true }) + try { + const integrity = copied.query("PRAGMA integrity_check").all() as Array<{ integrity_check: string }> + if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok") { + throw new Error(`SQLite integrity check failed for ${destination}`) + } + const foreign = copied.query("PRAGMA foreign_key_check").all() + if (foreign.length) throw new Error(`SQLite foreign-key check failed for ${destination}`) + } finally { + copied.close() + } + } + + async function verifyArtifacts(root: string) { + const database = path.join(root, "artifact-store", "artifacts.db") + if (!(await fs.lstat(database).catch(() => undefined))) return + const db = new Database(database, { readonly: true }) + try { + const rows = db.query("SELECT sha256, size, path FROM blobs ORDER BY sha256").all() as Array<{ + sha256: string + size: number + path: string + }> + for (const row of rows) { + if (!safeRelative(row.path)) throw new Error(`Artifact blob has an unsafe path: ${row.path}`) + const filepath = path.join(root, "artifact-store", row.path) + const stat = await fs.lstat(filepath).catch(() => undefined) + if ( + !stat?.isFile() || + stat.isSymbolicLink() || + stat.size !== row.size || + (await hash(filepath)) !== row.sha256 + ) { + throw new Error(`Artifact blob ${row.sha256} failed relocation verification`) + } + } + } finally { + db.close() + } + } + + async function snapshot(source: string, destination: string): Promise<{ files: number; bytes: number }> { + const records: Array<{ source: string; destination: string; bytes: number; sha256: string }> = [] + const stack: Array<{ source: string; destination: string; relative: string }> = [ + { source, destination, relative: "" }, + ] + while (stack.length) { + const current = stack.pop() + if (!current) continue + await fs.mkdir(current.destination, { recursive: true }) + const entries = await fs.readdir(current.source, { withFileTypes: true }) + for (const entry of entries) { + const relative = path.join(current.relative, entry.name) + if (relative === path.join("artifact-store", "partial")) continue + if (skipped.has(relative) || appTransient(relative, entry.name)) continue + const from = path.join(current.source, entry.name) + const to = path.join(current.destination, entry.name) + const stat = await fs.lstat(from) + if (entry.isDirectory()) { + await fs.mkdir(to, { recursive: true, mode: stat.mode & 0o777 }) + stack.push({ source: from, destination: to, relative }) + continue + } + if (entry.isSymbolicLink()) { + const resolved = await fs.realpath(from) + if (!inside(source, resolved)) throw new Error(`Data symlink escapes the active root: ${relative}`) + const mapped = path.join(destination, path.relative(source, resolved)) + const resolvedStat = await fs.stat(resolved) + await fs.symlink(path.relative(path.dirname(to), mapped), to, resolvedStat.isDirectory() ? "dir" : "file") + continue + } + if (!entry.isFile()) throw new Error(`Unsupported data entry during relocation: ${relative}`) + if (relative === path.join("artifact-store", "artifacts.db")) { + await snapshotDatabase(from, to) + const copied = await fs.stat(to) + records.push({ source: from, destination: to, bytes: copied.size, sha256: await hash(to) }) + continue + } + const before = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino, dev: stat.dev } + await fs.copyFile(from, to, fs.constants.COPYFILE_EXCL) + await fs.chmod(to, stat.mode & 0o777) + const [after, sourceHash, targetHash] = await Promise.all([fs.stat(from), hash(from), hash(to)]) + if ( + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ino !== before.ino || + after.dev !== before.dev || + sourceHash !== targetHash + ) { + throw new Error(`Data changed while it was being relocated: ${relative}`) + } + records.push({ source: from, destination: to, bytes: before.size, sha256: targetHash }) + } + } + + for (const record of records) { + const stat = await fs.lstat(record.destination) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== record.bytes) { + throw new Error(`Relocated file failed structural verification: ${record.destination}`) + } + if ((await hash(record.destination)) !== record.sha256) { + throw new Error(`Relocated file failed checksum verification: ${record.destination}`) + } + } + await verifyArtifacts(destination) + return { files: records.length, bytes: records.reduce((sum, record) => sum + record.bytes, 0) } + } + + async function destination(raw: string) { + const expanded = raw.replace(/^~(?=$|\/)/, Global.Path.home) + if (!path.isAbsolute(expanded)) throw new Error("Path must be absolute") + const target = path.resolve(expanded) + const parent = path.dirname(target) + await fs.mkdir(parent, { recursive: true }) + const canonicalParent = await fs.realpath(parent) + return path.join(canonicalParent, path.basename(target)) + } + + async function current() { + return fs.realpath(Global.Path.data) + } + + async function validateTarget(source: string, target: string, allowExisting: boolean) { + if (target === source) throw new Error("Already the current location") + if (inside(source, target)) throw new Error("Target cannot be inside the current data directory") + if (inside(target, source)) throw new Error("Target cannot contain the current data directory") + if (target === Global.Path.home || path.dirname(target) === target) { + throw new Error("Choose a dedicated data directory, not a home or filesystem root") + } + const stat = await fs.lstat(target).catch(() => undefined) + if (!stat) return + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Target must be an ordinary directory") + const contents = await fs.readdir(target) + if (contents.length && !allowExisting) throw new Error("Target directory is not empty") + } + + async function install(target: string, reset: boolean): Promise { + if (!Global.Path.dataManaged) { + throw new Error("Storage relocation is disabled when OPENSCIENCE_DATA_DIR explicitly owns the data root") + } + await using barrier = await DataRootBarrier.exclusive(120_000) + // Resolve the physical source only after this process owns the global + // relocation transaction. A queued second server must snapshot the root + // selected by the first switch, never the stale root it observed before + // waiting for the barrier. + const source = await current() + if (reset && source === target) throw new Error("The default data location is already active") + await validateTarget(source, target, reset) + const stage = path.join(path.dirname(target), `.${path.basename(target)}.openscience-${randomUUID()}`) + await MemoryIndex.reset().catch(() => undefined) + const copied = await snapshot(source, stage).catch(async (error) => { + await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined) + throw error + }) + const existing = await fs.lstat(target).catch(() => undefined) + const backup = + reset && existing ? `${target}.pre-reset-${new Date().toISOString().replaceAll(":", "-")}` : undefined + if (backup) await fs.rename(target, backup) + if (existing && !backup) await fs.rmdir(target) + await fs.rename(stage, target).catch(async (error) => { + if (backup) await fs.rename(backup, target).catch(() => undefined) + await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined) + throw error + }) + + await DataRoot.switchTo(Global.Path.data, target) + const compatibility = reset + ? await fs + .rm(pointer(), { force: true }) + .then(() => undefined) + .catch((error) => `The active data root changed, but the legacy pointer cleanup failed: ${String(error)}`) + : await atomicWrite(pointer(), `${target}\n`) + .then(() => undefined) + .catch((error) => `The active data root changed, but the legacy pointer update failed: ${String(error)}`) + return { + source, + target, + ...copied, + ...(backup ? { backup } : {}), + ...(compatibility ? { warning: compatibility } : {}), + } + } + + export async function relocate(raw: string): Promise { + const target = await destination(raw) + return install(target, false) + } + + export async function reset(): Promise { + const target = await destination(path.resolve(Global.Path.home, ".openscience")) + return install(target, true) + } +} diff --git a/backend/cli/src/global/data-root-barrier.ts b/backend/cli/src/global/data-root-barrier.ts new file mode 100644 index 00000000..f701bfc7 --- /dev/null +++ b/backend/cli/src/global/data-root-barrier.ts @@ -0,0 +1,277 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { ProcessIdentity } from "../process/process-identity" + +/** + * Cross-process drain barrier used only for data-root relocation. + * + * Writers publish small operation markers in the config directory. A switch + * first publishes an intent (which blocks new markers), then waits for every + * marker owned by a live OpenScience process to disappear. The config root is + * deliberately outside the switchable data root. + */ +export namespace DataRootBarrier { + export interface Owner { + pid: number + identity: string + } + + export interface Operation extends AsyncDisposable { + reassign(owner: Owner): Promise + } + + interface Record { + pid?: number + identity?: string + token?: string + } + + type Configuration = { root: string; config: string } + let configuration: Configuration | undefined + let self: Promise | undefined + + const pause = 20 + const wait = 30_000 + + export function configure(value: Configuration) { + configuration = value + } + + function paths(config: string) { + return { + intent: path.join(config, "data-root-switch.intent"), + lock: path.join(config, "data-root-switch.lock"), + operations: path.join(config, "data-root-operations"), + } + } + + function relevant(filepath: string, root: string) { + const relative = path.relative(root, filepath) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) + } + + function running(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } + } + + async function owner(filepath: string): Promise { + return Bun.file(filepath) + .json() + .then((value) => (value && typeof value === "object" ? value : undefined)) + .catch(() => undefined) + } + + async function exactOwner(value?: Owner): Promise { + if (value) { + if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || !/^[a-f0-9]{64}$/.test(value.identity)) { + throw new Error("A data-root operation owner requires an exact process identity") + } + if (!(await ProcessIdentity.owns(value.pid, value.identity))) { + throw new Error(`Data-root operation owner ${value.pid} is no longer the recorded process`) + } + return value + } + self ??= ProcessIdentity.capture(process.pid).then((identity) => { + if (!identity) throw new Error(`Could not establish an exact identity for OpenScience process ${process.pid}`) + return { pid: process.pid, identity } + }) + return self + } + + async function liveOwner(record: Record | undefined): Promise { + if (typeof record?.pid !== "number") return false + if (record.identity) return ProcessIdentity.owns(record.pid, record.identity) + // Compatibility for an operation marker written by an older process. + // New markers always include an exact process-start identity. + return running(record.pid) + } + + async function waitForIntent(intent: string, deadline: number) { + while (await fs.lstat(intent).catch(() => undefined)) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for the active data relocation to finish") + const record = await owner(intent) + if (typeof record?.pid === "number" && !(await liveOwner(record))) { + const aside = `${intent}.${randomUUID()}.dead` + const claimed = await fs + .rename(intent, aside) + .then(() => true) + .catch(() => false) + if (claimed) await fs.rm(aside, { force: true }) + continue + } + await Bun.sleep(pause) + } + } + + /** Mark one durable operation. Paths outside the managed root are no-ops. */ + export async function enter(filepath: string, timeoutMs = wait, requestedOwner?: Owner): Promise { + const current = configuration + if (!current || !relevant(path.resolve(filepath), path.resolve(current.root))) { + return { + async reassign() {}, + async [Symbol.asyncDispose]() {}, + } + } + + const { intent, operations } = paths(current.config) + const deadline = Date.now() + timeoutMs + const operationOwner = await exactOwner(requestedOwner) + await fs.mkdir(operations, { recursive: true }) + const token = randomUUID() + const marker = path.join(operations, `${operationOwner.pid}.${token}.json`) + for (;;) { + await waitForIntent(intent, deadline) + const handle = await fs.open(marker, "wx", 0o600) + try { + await handle.writeFile(JSON.stringify({ ...operationOwner, token, created: Date.now() })) + await handle.sync() + } catch (error) { + await handle.close().catch(() => undefined) + await fs.rm(marker, { force: true }).catch(() => undefined) + throw error + } + if (!(await fs.lstat(intent).catch(() => undefined))) { + let pending = Promise.resolve() + let disposed = false + return { + reassign(value: Owner) { + pending = pending.then(async () => { + if (disposed) throw new Error("Cannot reassign a closed data-root operation") + const nextOwner = await exactOwner(value) + const temporary = path.join(current.config, `.data-root-operation-${token}.${randomUUID()}.next`) + const replacement = await fs.open(temporary, "wx", 0o600) + try { + await replacement.writeFile(JSON.stringify({ ...nextOwner, token, created: Date.now() })) + await replacement.sync() + await replacement.close() + await fs.rename(temporary, marker) + } catch (error) { + await replacement.close().catch(() => undefined) + await fs.rm(temporary, { force: true }).catch(() => undefined) + throw error + } + }) + return pending + }, + async [Symbol.asyncDispose]() { + await pending + disposed = true + await handle.close().catch(() => undefined) + const record = await owner(marker) + if (record?.token === token) await fs.rm(marker, { force: true }).catch(() => undefined) + }, + } + } + await handle.close().catch(() => undefined) + await fs.rm(marker, { force: true }).catch(() => undefined) + } + } + + /** Keep a marker alive until the asynchronous operation has actually + * settled. Returning an un-awaited Promise from an `await using` scope + * releases the marker too early, so request/CLI boundaries use this helper. */ + export async function during(filepath: string, action: () => Promise, timeoutMs = wait): Promise { + await using operation = await enter(filepath, timeoutMs) + return await action() + } + + async function acquire(filepath: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs + const token = randomUUID() + const operationOwner = await exactOwner() + for (;;) { + const handle = await fs.open(filepath, "wx", 0o600).catch(async (error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error + const record = await owner(filepath) + if (typeof record?.pid === "number" && !(await liveOwner(record))) { + const aside = `${filepath}.${randomUUID()}.dead` + const claimed = await fs + .rename(filepath, aside) + .then(() => true) + .catch(() => false) + if (claimed) await fs.rm(aside, { force: true }) + if (claimed) return + } + if (Date.now() >= deadline) throw new Error("Timed out waiting for another data relocation") + await Bun.sleep(pause) + }) + if (!handle) continue + try { + await handle.writeFile(JSON.stringify({ ...operationOwner, token, created: Date.now() })) + await handle.sync() + } catch (error) { + await handle.close().catch(() => undefined) + await fs.rm(filepath, { force: true }).catch(() => undefined) + throw error + } + return { handle, token } + } + } + + /** Block new operations and wait for every pre-existing writer to drain. */ + export async function exclusive(timeoutMs = wait): Promise { + const current = configuration + if (!current) throw new Error("The data-root barrier has not been configured") + const state = paths(current.config) + await fs.mkdir(state.operations, { recursive: true }) + const lock = await acquire(state.lock, timeoutMs) + const intent = await fs.open(state.intent, "wx", 0o600).catch(async (error: NodeJS.ErrnoException) => { + await lock.handle.close().catch(() => undefined) + await fs.rm(state.lock, { force: true }).catch(() => undefined) + throw error + }) + const intentToken = randomUUID() + const intentOwner = await exactOwner() + try { + await intent.writeFile(JSON.stringify({ ...intentOwner, token: intentToken, created: Date.now() })) + await intent.sync() + } catch (error) { + await intent.close().catch(() => undefined) + await fs.rm(state.intent, { force: true }).catch(() => undefined) + await lock.handle.close().catch(() => undefined) + await fs.rm(state.lock, { force: true }).catch(() => undefined) + throw error + } + + const deadline = Date.now() + timeoutMs + for (;;) { + const entries = await fs.readdir(state.operations).catch(() => []) + const live: string[] = [] + for (const name of entries) { + const marker = path.join(state.operations, name) + const record = await owner(marker) + if (await liveOwner(record)) { + live.push(name) + continue + } + await fs.rm(marker, { force: true }).catch(() => undefined) + } + if (!live.length) break + if (Date.now() >= deadline) { + await intent.close().catch(() => undefined) + await fs.rm(state.intent, { force: true }).catch(() => undefined) + await lock.handle.close().catch(() => undefined) + await fs.rm(state.lock, { force: true }).catch(() => undefined) + throw new Error(`Active OpenScience operations did not quiesce: ${live.join(", ")}`) + } + await Bun.sleep(pause) + } + + return { + async [Symbol.asyncDispose]() { + await intent.close().catch(() => undefined) + const activeIntent = await owner(state.intent) + if (activeIntent?.token === intentToken) await fs.rm(state.intent, { force: true }).catch(() => undefined) + await lock.handle.close().catch(() => undefined) + const activeLock = await owner(state.lock) + if (activeLock?.token === lock.token) await fs.rm(state.lock, { force: true }).catch(() => undefined) + }, + } + } +} diff --git a/backend/cli/src/global/data-root.ts b/backend/cli/src/global/data-root.ts new file mode 100644 index 00000000..26a31876 --- /dev/null +++ b/backend/cli/src/global/data-root.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { WindowsJunction } from "./windows-junction" + +/** + * Stable indirection for the mutable OpenScience data root. + * + * Most persistence modules intentionally compute their paths once at module + * load. A settings-time relocation therefore cannot change a process-local + * string without leaving half the process on the old root. All normal boots + * instead point those strings through one directory link in the XDG config + * directory. Retargeting that link moves every existing path in every server + * process after the cross-process relocation barrier drains active writers. + */ +export namespace DataRoot { + export const LINK_NAME = "data-root" + + export interface Managed { + path: string + target: string + managed: boolean + } + + function inside(parent: string, candidate: string) { + const relative = path.relative(parent, candidate) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) + } + + async function targetOf(link: string): Promise { + const stat = await fs.lstat(link).catch(() => undefined) + if (!stat?.isSymbolicLink()) return + const target = await fs.realpath(link).catch(() => undefined) + if (!target) return + const targetStat = await fs.stat(target).catch(() => undefined) + return targetStat?.isDirectory() ? target : undefined + } + + /** Read the active physical target without creating the indirection. */ + export async function active(config: string): Promise { + return targetOf(path.join(config, LINK_NAME)) + } + + async function link(target: string, destination: string) { + const current = await fs.lstat(destination).catch(() => undefined) + if (process.platform === "win32" && current?.isSymbolicLink()) { + WindowsJunction.retarget(destination, target) + return + } + const temporary = `${destination}.${process.pid}.${randomUUID()}.next` + await fs.symlink(target, temporary, process.platform === "win32" ? "junction" : "dir") + try { + await fs.rename(temporary, destination) + } catch (error) { + await fs.rm(temporary, { force: true }).catch(() => undefined) + // Another process may have established the managed junction after our + // initial lstat. Windows cannot rename over it; update that same reparse + // record in place. Ordinary files/directories fail closed in CreateFile + // or with a reparse-tag mismatch. + const winner = await fs.lstat(destination).catch(() => undefined) + if (process.platform === "win32" && winner?.isSymbolicLink()) { + WindowsJunction.retarget(destination, target) + return + } + throw error + } + } + + /** + * Establish the stable link on first boot. Explicit test/administrator data + * roots deliberately remain direct: they are already an external authority + * and may share no XDG config directory with sibling test processes. + */ + export async function ensure(config: string, initial: string, explicit: boolean): Promise { + const requested = path.resolve(initial) + await fs.mkdir(requested, { recursive: true }) + const target = await fs.realpath(requested) + if (explicit) return { path: target, target, managed: false } + + await fs.mkdir(config, { recursive: true }) + const destination = path.join(config, LINK_NAME) + const current = await targetOf(destination) + if (current) return { path: destination, target: current, managed: true } + + const existing = await fs.lstat(destination).catch(() => undefined) + if (existing) { + throw new Error( + `${destination} must be a managed OpenScience directory link, but an ordinary file or directory exists there`, + ) + } + await link(target, destination) + return { path: destination, target, managed: true } + } + + /** Retarget the stable data-root link while the relocation barrier has + * drained OpenScience writers. POSIX replaces the link name atomically; + * Windows updates the existing junction's reparse record in place because + * Win32 cannot rename over a directory junction. */ + export async function switchTo(root: string, target: string): Promise { + const requested = path.resolve(target) + const stat = await fs.stat(requested).catch(() => undefined) + if (!stat?.isDirectory()) throw new Error(`Data target does not exist or is not a directory: ${requested}`) + const destination = await fs.realpath(requested) + if (inside(destination, root)) throw new Error("The managed data-root link cannot live inside its own target") + await link(destination, root) + const selected = await fs.realpath(root) + if (selected !== destination) { + throw new Error(`Data-root switch selected ${selected}, expected ${destination}`) + } + } +} diff --git a/backend/cli/src/global/index.ts b/backend/cli/src/global/index.ts index 33eb42f9..585b369b 100644 --- a/backend/cli/src/global/index.ts +++ b/backend/cli/src/global/index.ts @@ -4,6 +4,8 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" import { resolveDataDirectory } from "./data-dir" +import { DataRoot } from "./data-root" +import { DataRootBarrier } from "./data-root-barrier" const app = "openscience" @@ -57,13 +59,15 @@ const state = migrateDir(xdgState!) // file exists (config/data-location) we honour it; otherwise ~/.openscience. // Resolve once at boot so every Global.Path.data consumer sees one value. const explicit = override("OPENSCIENCE_DATA_DIR") -const pointer = (() => { +const storedPointer = (() => { try { return readFileSync(path.join(config, "data-location"), "utf8").trim() || undefined } catch { return } })() +const anchored = explicit ? undefined : await DataRoot.active(config) +const pointer = anchored ?? storedPointer const previous = migrateDir(xdgData!) const resolved = await resolveDataDirectory({ home: process.env.OPENSCIENCE_TEST_HOME || os.homedir(), @@ -71,7 +75,28 @@ const resolved = await resolveDataDirectory({ explicit, pointer, }) -const data = resolved.path +const selected = await DataRoot.ensure(config, resolved.path, !!explicit) +const data = selected.path +DataRootBarrier.configure({ root: data, config }) + +// The stable link is authoritative. Reconcile the compatibility pointer after +// an interrupted switch so an older OpenScience build selects the same root. +if (selected.managed) { + const defaultRoot = await fs + .realpath(path.join(process.env.OPENSCIENCE_TEST_HOME || os.homedir(), ".openscience")) + .catch(() => path.resolve(process.env.OPENSCIENCE_TEST_HOME || os.homedir(), ".openscience")) + const pointerPath = path.join(config, "data-location") + if (selected.target === defaultRoot) { + await fs.rm(pointerPath, { force: true }).catch(() => undefined) + } else if (storedPointer !== selected.target) { + const temporary = `${pointerPath}.${process.pid}.${crypto.randomUUID()}.tmp` + await Bun.write(temporary, `${selected.target}\n`, { mode: 0o600 }) + await fs.rename(temporary, pointerPath).catch(async (error) => { + await fs.rm(temporary, { force: true }).catch(() => undefined) + throw error + }) + } +} // Legacy file names inside the migrated dirs (pre-rename releases). migrateFile(data, "synsci-session.json", "openscience-session.json") @@ -87,13 +112,18 @@ export namespace Global { * as a permanent duplicate nothing ever tells the user they can delete. * `openscience doctor` reports it and can remove it. Undefined once the * data root is the same directory or the user has cleaned it up. */ - export const LegacyData = previous === data ? undefined : previous + export const LegacyData = previous === selected.target ? undefined : previous export const Path = { // Allow override via OPENSCIENCE_TEST_HOME for test isolation get home() { return process.env.OPENSCIENCE_TEST_HOME || os.homedir() }, data, + dataManaged: selected.managed, + /** Current physical destination behind the stable data-root link. */ + get dataTarget() { + return selected.managed ? fs.realpath(data).catch(() => selected.target) : Promise.resolve(selected.target) + }, bin: path.join(data, "bin"), log: path.join(data, "log"), cache, diff --git a/backend/cli/src/global/windows-junction.ts b/backend/cli/src/global/windows-junction.ts new file mode 100644 index 00000000..02e59538 --- /dev/null +++ b/backend/cli/src/global/windows-junction.ts @@ -0,0 +1,133 @@ +import path from "node:path" +import { dlopen, FFIType } from "bun:ffi" + +/** + * In-place retargeting for a Windows directory junction. + * + * MoveFileEx cannot replace an existing directory, including a junction, so + * `rename(newJunction, existingJunction)` is not a Windows swap primitive. + * FSCTL_SET_REPARSE_POINT can modify an existing mount-point reparse record + * when its tag matches, keeping the stable directory entry in place. The + * cross-process relocation barrier prevents OpenScience writes during this + * operation; the post-update realpath check confirms the requested target. + */ +export namespace WindowsJunction { + type Handle = number | bigint + + export const IO_REPARSE_TAG_MOUNT_POINT = 0xa0000003 + export const FSCTL_SET_REPARSE_POINT = 0x000900a4 + export const FSCTL_GET_REPARSE_POINT = 0x000900a8 + + const GENERIC_WRITE = 0x40000000 + const FILE_SHARE_READ = 0x00000001 + const FILE_SHARE_WRITE = 0x00000002 + const FILE_SHARE_DELETE = 0x00000004 + const OPEN_EXISTING = 3 + const FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 + const FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 + const INVALID_HANDLE_VALUE = 0xffffffffffffffffn + + const definitions = { + CreateFileW: { + args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], + returns: FFIType.u64, + }, + DeviceIoControl: { + args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + CloseHandle: { + args: [FFIType.u64], + returns: FFIType.i32, + }, + GetLastError: { + args: [], + returns: FFIType.u32, + }, + } as const + + const openKernel = () => dlopen("kernel32.dll", definitions) + let kernel: ReturnType | undefined + + function api() { + if (process.platform !== "win32") throw new Error("Windows junction retargeting is only available on Windows") + if (process.arch !== "x64" && process.arch !== "arm64") { + throw new Error(`Windows junction retargeting requires a 64-bit runtime, received ${process.arch}`) + } + kernel ??= openKernel() + return kernel.symbols + } + + function wide(value: string): Buffer { + return Buffer.from(`${value}\0`, "utf16le") + } + + function invalid(handle: Handle): boolean { + return BigInt(handle) === INVALID_HANDLE_VALUE + } + + function substitute(target: string): string { + if (target.startsWith("\\\\?\\UNC\\")) return `\\??\\UNC\\${target.slice(8)}` + if (target.startsWith("\\\\?\\")) return `\\??\\${target.slice(4)}` + if (target.startsWith("\\\\")) return `\\??\\UNC\\${target.slice(2)}` + return `\\??\\${target}` + } + + function buffer(target: string): Buffer { + const print = path.win32.resolve(target) + const internal = substitute(print) + const internalBytes = Buffer.from(internal, "utf16le") + const printBytes = Buffer.from(print, "utf16le") + const paths = Buffer.concat([internalBytes, Buffer.alloc(2), printBytes, Buffer.alloc(2)]) + const data = Buffer.alloc(16 + paths.length) + data.writeUInt32LE(IO_REPARSE_TAG_MOUNT_POINT, 0) + data.writeUInt16LE(8 + paths.length, 4) + data.writeUInt16LE(0, 6) + data.writeUInt16LE(0, 8) + data.writeUInt16LE(internalBytes.length, 10) + data.writeUInt16LE(internalBytes.length + 2, 12) + data.writeUInt16LE(printBytes.length, 14) + paths.copy(data, 16) + if (data.length > 16 * 1024) throw new Error(`Junction target is too long: ${print}`) + return data + } + + export function retarget(junction: string, target: string): void { + const symbols = api() + const handle = symbols.CreateFileW( + wide(junction), + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) as Handle + if (invalid(handle)) { + throw new Error(`CreateFileW(${junction}) failed (Win32 error ${Number(symbols.GetLastError())})`) + } + try { + const current = Buffer.alloc(16 * 1024) + const currentSize = Buffer.alloc(4) + if ( + !symbols.DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, null, 0, current, current.length, currentSize, null) + ) { + throw new Error(`FSCTL_GET_REPARSE_POINT(${junction}) failed (Win32 error ${Number(symbols.GetLastError())})`) + } + if (current.readUInt32LE(0) !== IO_REPARSE_TAG_MOUNT_POINT) { + throw new Error(`${junction} is not a managed Windows directory junction`) + } + const input = buffer(target) + const returned = Buffer.alloc(4) + if (!symbols.DeviceIoControl(handle, FSCTL_SET_REPARSE_POINT, input, input.length, null, 0, returned, null)) { + throw new Error(`FSCTL_SET_REPARSE_POINT(${junction}) failed (Win32 error ${Number(symbols.GetLastError())})`) + } + } finally { + symbols.CloseHandle(handle) + } + } + + export function bufferForTests(target: string): Buffer { + return buffer(target) + } +} diff --git a/backend/cli/src/index.ts b/backend/cli/src/index.ts index 09d00e66..190951ca 100644 --- a/backend/cli/src/index.ts +++ b/backend/cli/src/index.ts @@ -40,6 +40,41 @@ import { LocalCommand } from "./cli/cmd/local" import { SandboxCommand } from "./cli/cmd/sandbox" import { InitCommand, DoctorCommand } from "./cli/onboard" import { OpenScience } from "./openscience" +import { GROUP_LAUNCHER_ARG, run as runMcpGroupLauncher } from "./mcp/group-launcher" +import { WINDOWS_JOB_LAUNCHER_ARG, WindowsJobLauncher } from "./process/windows-job-launcher" +import { + DARWIN_RESPONSIBILITY_LAUNCHER_ARG, + DarwinResponsibilityLauncher, +} from "./process/darwin-responsibility-launcher" +import { DataRootBarrier } from "./global/data-root-barrier" +import { Global } from "./global" + +if (process.argv[2] === WINDOWS_JOB_LAUNCHER_ARG) { + try { + process.exit(await WindowsJobLauncher.run(process.argv.slice(3))) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +if (process.argv[2] === DARWIN_RESPONSIBILITY_LAUNCHER_ARG) { + try { + process.exit(await DarwinResponsibilityLauncher.run(process.argv.slice(3))) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +if (process.argv[2] === GROUP_LAUNCHER_ARG) { + try { + process.exit(await runMcpGroupLauncher(process.argv.slice(3))) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -53,6 +88,8 @@ process.on("uncaughtException", (e) => { }) }) +const cliDataRootOperation = { current: undefined as AsyncDisposable | undefined } + const cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("openscience") @@ -71,6 +108,13 @@ const cli = yargs(hideBin(process.argv)) choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) .middleware(async (opts) => { + const command = typeof opts._[0] === "string" ? opts._[0] : "web" + if (command !== "web" && command !== "serve" && !cliDataRootOperation.current) { + // Non-server CLI commands can mutate the same local stores as a running + // workspace. Hold one cross-process operation marker for the entire + // command so a live relocation either precedes it or waits for it. + cliDataRootOperation.current = await DataRootBarrier.enter(Global.Path.data, 120_000) + } await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isLocal(), @@ -80,6 +124,7 @@ const cli = yargs(hideBin(process.argv)) return "INFO" })(), }) + OpenScience.reportApiBaseOverride() process.env.AGENT = "1" process.env.OPENSCIENCE = "1" @@ -201,5 +246,9 @@ try { // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. + if (cliDataRootOperation.current) { + await Promise.resolve(cliDataRootOperation.current[Symbol.asyncDispose]()).catch(() => undefined) + } + await Log.flush().catch(() => undefined) process.exit() } diff --git a/backend/cli/src/installation/index.ts b/backend/cli/src/installation/index.ts index fbbee240..64651a34 100644 --- a/backend/cli/src/installation/index.ts +++ b/backend/cli/src/installation/index.ts @@ -4,8 +4,9 @@ import { $ } from "bun" import z from "zod" import { NamedError } from "@synsci/util/error" import { Log } from "../util/log" -import { iife } from "@/util/iife" import { Flag } from "../flag/flag" +import fs from "node:fs/promises" +import os from "node:os" declare global { const OPENSCIENCE_VERSION: string @@ -16,6 +17,14 @@ declare global { export namespace Installation { const log = Log.create({ service: "installation" }) + const RELEASE_TIMEOUT_MS = 10_000 + + function releaseFetch(input: string | URL | Request, init: RequestInit = {}) { + return fetch(input, { + ...init, + signal: init.signal ?? AbortSignal.timeout(RELEASE_TIMEOUT_MS), + }) + } export type Method = Awaited> @@ -59,73 +68,32 @@ export namespace Installation { return CHANNEL === "local" } - export async function method() { - if (process.execPath.includes(path.join(".openscience", "bin"))) return "curl" + export function methodFromPaths(input: { execPath: string; scriptPath?: string }) { + const exec = input.execPath.replaceAll("\\", "/").toLowerCase() + const script = (input.scriptPath ?? "").replaceAll("\\", "/").toLowerCase() + const installed = `${exec}\n${script}` + + if (exec.includes("/.openscience/bin/") || exec.includes("/.synsc/bin/")) return "curl" as const // legacy pre-rename curl installs lived under ~/.synsc/bin - if (process.execPath.includes(path.join(".synsc", "bin"))) return "curl" // ~/.local/bin is ALSO npm's target with `--prefix ~/.local`, pipx, and many - // package managers — so it's ambiguous. Defer it: let the package-manager - // probes below claim the install first, and only fall back to "curl" for - // .local/bin when none of them do (see after the loop). Otherwise a - // `npm i -g` into ~/.local was upgraded with the curl script. - const inLocalBin = process.execPath.includes(path.join(".local", "bin")) - const exec = process.execPath.toLowerCase() - - const checks = [ - { - name: "npm" as const, - command: () => $`npm list -g --depth=0`.throws(false).quiet().text(), - }, - { - name: "yarn" as const, - command: () => $`yarn global list`.throws(false).quiet().text(), - }, - { - name: "pnpm" as const, - command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(), - }, - { - name: "bun" as const, - command: () => $`bun pm ls -g`.throws(false).quiet().text(), - }, - { - name: "brew" as const, - command: () => $`brew list --formula openscience`.throws(false).quiet().text(), - }, - { - name: "scoop" as const, - command: () => $`scoop list openscience`.throws(false).quiet().text(), - }, - { - name: "choco" as const, - command: () => $`choco list --limit-output openscience`.throws(false).quiet().text(), - }, - ] - - checks.sort((a, b) => { - const aMatches = exec.includes(a.name) - const bMatches = exec.includes(b.name) - if (aMatches && !bMatches) return -1 - if (!aMatches && bMatches) return 1 - return 0 - }) - - for (const check of checks) { - const output = await check.command() - const installedName = - check.name === "brew" || check.name === "choco" || check.name === "scoop" - ? "openscience" - : "@synsci/openscience" - if (output.includes(installedName)) { - return check.name - } - } - - // No package manager claimed it — now honor the ambiguous ~/.local/bin as a - // curl install (the curl installer's default target). - if (inLocalBin) return "curl" + // package managers. Prefer the wrapper's own immutable location over + // running package-manager discovery inside a user project: yarnPath, + // npmrc, PATH, or similar project configuration must never execute during + // a background update check. + if (installed.includes("/.bun/install/global/")) return "bun" as const + if (installed.includes("/.config/yarn/global/") || installed.includes("/yarn/global/")) return "yarn" as const + if (installed.includes("/.pnpm/") || installed.includes("/pnpm/global/")) return "pnpm" as const + if (installed.includes("/scoop/apps/openscience/")) return "scoop" as const + if (installed.includes("/chocolatey/")) return "choco" as const + if (installed.includes("/cellar/openscience/")) return "brew" as const + if (installed.includes("/node_modules/@synsci/openscience-")) return "npm" as const + if (script.includes("/node_modules/@synsci/openscience/")) return "npm" as const + if (exec.includes("/.local/bin/")) return "curl" as const + return "unknown" as const + } - return "unknown" + export async function method() { + return methodFromPaths({ execPath: process.execPath, scriptPath: process.argv[1] }) } export const UpgradeFailedError = NamedError.create( @@ -144,16 +112,36 @@ export namespace Installation { } export async function upgrade(method: Method, target: string) { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-upgrade-")) + const allowed = [ + "PATH", + "HOME", + "USERPROFILE", + "TMPDIR", + "TMP", + "TEMP", + "SystemRoot", + "COMSPEC", + "PATHEXT", + "APPDATA", + "LOCALAPPDATA", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "https_proxy", + "http_proxy", + "no_proxy", + ] + const env = Object.fromEntries(allowed.flatMap((key) => (process.env[key] ? [[key, process.env[key]!]] : []))) let cmd switch (method) { case "curl": // openscience.sh/install serves the repo install script. The app // subdomain serves the dashboard SPA, so piping it into bash fails. // Override via OPENSCIENCE_INSTALL_URL if hosting the script elsewhere. - cmd = $`curl -fsSL ${process.env.OPENSCIENCE_INSTALL_URL || "https://openscience.sh/install"} | bash`.env({ - ...process.env, - VERSION: target, - }) + cmd = $`curl -fsSL ${process.env.OPENSCIENCE_INSTALL_URL || "https://openscience.sh/install"} | bash` break case "npm": cmd = $`npm install -g @synsci/openscience@${target}` @@ -166,10 +154,7 @@ export namespace Installation { break case "brew": { const formula = await getBrewFormula() - cmd = $`brew upgrade ${formula}`.env({ - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - }) + cmd = $`brew upgrade ${formula}` break } case "choco": @@ -181,20 +166,31 @@ export namespace Installation { default: throw new Error(`Unknown method: ${method}`) } - const result = await cmd.quiet().throws(false) - if (result.exitCode !== 0) { - const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8") - throw new UpgradeFailedError({ - stderr: stderr, + const commandEnv = + method === "curl" + ? { ...env, VERSION: target } + : method === "brew" + ? { ...env, HOMEBREW_NO_AUTO_UPDATE: "1" } + : env + try { + const result = await cmd.cwd(cwd).env(commandEnv).quiet().throws(false) + if (result.exitCode !== 0) { + const stderr = + method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8") + throw new UpgradeFailedError({ + stderr: stderr, + }) + } + log.info("upgraded", { + method, + target, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), }) + await $`${process.execPath} --version`.cwd(cwd).env(env).nothrow().quiet().text() + } finally { + await fs.rm(cwd, { recursive: true, force: true }) } - log.info("upgraded", { - method, - target, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - }) - await $`${process.execPath} --version`.nothrow().quiet().text() } export const VERSION = typeof OPENSCIENCE_VERSION === "string" ? OPENSCIENCE_VERSION : "local" @@ -229,15 +225,12 @@ export namespace Installation { const detectedMethod = installMethod || (await method()) if (detectedMethod === "brew") { - const formula = await getBrewFormula() - if (formula === "openscience") { - return fetch("https://formulae.brew.sh/api/formula/openscience.json") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.versions.stable) - } + return releaseFetch("https://formulae.brew.sh/api/formula/openscience.json") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.versions.stable) } if ( @@ -246,13 +239,8 @@ export namespace Installation { detectedMethod === "pnpm" || detectedMethod === "unknown" ) { - const registry = await iife(async () => { - const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() - const reg = r || "https://registry.npmjs.org" - return reg.endsWith("/") ? reg.slice(0, -1) : reg - }) const channel = npmReleaseChannel() - return fetch(`${registry}/@synsci/openscience/${channel}`) + return releaseFetch(`https://registry.npmjs.org/@synsci/openscience/${channel}`) .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() @@ -261,7 +249,7 @@ export namespace Installation { } if (detectedMethod === "choco") { - return fetch(chocoLatestVersionUrl(), { headers: { Accept: "application/json;odata=verbose" } }) + return releaseFetch(chocoLatestVersionUrl(), { headers: { Accept: "application/json;odata=verbose" } }) .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() @@ -270,7 +258,7 @@ export namespace Installation { } if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/openscience.json", { + return releaseFetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/openscience.json", { headers: { Accept: "application/json" }, }) .then((res) => { @@ -280,7 +268,7 @@ export namespace Installation { .then((data: any) => data.version) } - return fetch("https://api.github.com/repos/synthetic-sciences/OpenScience/releases/latest") + return releaseFetch("https://api.github.com/repos/synthetic-sciences/OpenScience/releases/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() diff --git a/backend/cli/src/lsp/client.ts b/backend/cli/src/lsp/client.ts index 2c4cff02..48522120 100644 --- a/backend/cli/src/lsp/client.ts +++ b/backend/cli/src/lsp/client.ts @@ -15,6 +15,60 @@ import { Filesystem } from "../util/filesystem" import { ProjectTrust } from "../project/trust" const DIAGNOSTICS_DEBOUNCE_MS = 150 +const INITIALIZE_TIMEOUT_MS = 45_000 + +function waitForInitialize(input: { + request: () => Promise + process: LSPServer.Handle["process"] + serverID: string + timeoutMs: number +}): Promise { + return new Promise((resolve, reject) => { + let settled = false + const cleanup = () => { + clearTimeout(timeout) + input.process.off("error", onError) + input.process.off("exit", onExit) + } + const finish = (action: () => void) => { + if (settled) return + settled = true + cleanup() + action() + } + const onError = (error: Error) => finish(() => reject(error)) + const onExit = (code: number | null, signal: NodeJS.Signals | null) => + finish(() => { + const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}` + reject(new Error(`Language server ${input.serverID} exited during initialization (${reason})`)) + }) + const timeout = setTimeout( + () => finish(() => reject(new Error(`Operation timed out after ${input.timeoutMs}ms`))), + input.timeoutMs, + ) + + input.process.once("error", onError) + input.process.once("exit", onExit) + // A fast failure can occur after spawn() returns but before these listeners + // are installed. ChildProcess preserves an exit code/signal (and lacks a + // pid after a spawn error), so close that observation gap explicitly. + if (input.process.exitCode !== null || input.process.signalCode !== null) { + onExit(input.process.exitCode, input.process.signalCode) + } else if (input.process.pid === undefined) { + onError(new Error(`Language server ${input.serverID} failed to spawn`)) + } + + if (settled) return + try { + input.request().then( + (result) => finish(() => resolve(result)), + (error) => finish(() => reject(error)), + ) + } catch (error) { + onError(error instanceof Error ? error : new Error(String(error))) + } + }) +} export namespace LSPClient { const log = Log.create({ service: "lsp.client" }) @@ -40,7 +94,12 @@ export namespace LSPClient { ), } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + export async function create(input: { + serverID: string + server: LSPServer.Handle + root: string + initializationTimeoutMs?: number + }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -80,43 +139,52 @@ export namespace LSPClient { connection.listen() l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, + await waitForInitialize({ + process: input.server.process, + serverID: input.serverID, + timeoutMs: input.initializationTimeoutMs ?? INITIALIZE_TIMEOUT_MS, + request: () => + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, }, + ], + initializationOptions: { + ...input.server.initialization, }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, + capabilities: { + window: { + workDoneProgress: true, }, - publishDiagnostics: { - versionSupport: true, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, + }, }, }, - }, - }), - 45_000, - ).catch((err) => { + }), + }).catch((err) => { l.error("initialize error", { error: err }) + try { + connection.end() + } catch { + // The transport may already be closed because the server exited. + } + connection.dispose() throw new InitializeError( { serverID: input.serverID }, { diff --git a/backend/cli/src/lsp/index.ts b/backend/cli/src/lsp/index.ts index 8ce4cd7d..f8c23774 100644 --- a/backend/cli/src/lsp/index.ts +++ b/backend/cli/src/lsp/index.ts @@ -4,18 +4,26 @@ import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" import { pathToFileURL } from "url" -import { LSPServer } from "./server" +import { LSPServer, spawnLSPChild, withLSPSandbox } from "./server" import z from "zod" import { Config } from "../config/config" -import { spawn } from "child_process" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { OpenScience } from "@/openscience" import { ProjectTrust } from "@/project/trust" +import { CredentialProcessLedger } from "@/credentials/process-ledger" export namespace LSP { const log = Log.create({ service: "lsp" }) + async function completeProcess(id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await CredentialProcessLedger.complete(id)) return + await Bun.sleep(20) + } + throw new Error(`Language-server process ${id} did not exit after completion`) + } + export const Event = { Updated: BusEvent.define("lsp.updated", z.object({})), } @@ -91,6 +99,9 @@ export namespace LSP { servers, clients, spawning: new Map>(), + processes: new Set(), + generation: 0, + projectID: Instance.project.id, } } @@ -111,10 +122,16 @@ export namespace LSP { servers[name] = { ...existing, id: name, + configured: true, root: existing?.root ?? (async () => Instance.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => { - const env = { ...(await OpenScience.subprocessEnv(process.env)), ...item.env } + // Language servers need runtime/toolchain discovery, not account, + // provider, or cloud credentials. Explicit per-LSP config remains + // available for servers that genuinely require custom variables. + // Project/global config may use {env:SECRET}; pass only the + // credential-free runtime subset needed for toolchain discovery. + const env: Record = OpenScience.kernelEnv({ ...process.env, ...item.env }) const command = item.command[0] const target = path.isAbsolute(command) ? command @@ -125,7 +142,7 @@ export namespace LSP { target !== null && (Instance.containsPath(target) || (await Instance.containsCanonicalPath(target))) if (project || local) await ProjectTrust.require(Instance.project, "project_lsp") return { - process: spawn(item.command[0], item.command.slice(1), { + process: await spawnLSPChild(item.command[0], item.command.slice(1), { cwd: root, env, }), @@ -147,10 +164,23 @@ export namespace LSP { servers, clients, spawning: new Map>(), + processes: new Set(), + generation: 0, + projectID: Instance.project.id, } }, async (state) => { - await Promise.all(state.clients.map((client) => client.shutdown())) + // Revoke while every registered leader is still alive. The durable + // ledger snapshots the live PPID descendant closure here, including a + // direct child that already moved into its own process group. Killing a + // leader first would reparent that child and destroy the only safe link. + await CredentialProcessLedger.revoke({ kind: "lsp", projectID: state.projectID }) + for (const process of state.processes) process.kill() + state.processes.clear() + const clients = state.clients.splice(0) + const results = await Promise.allSettled(clients.map((client) => client.shutdown())) + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) log.warn("Language-server client cleanup failed after durable revocation", { failures }) }, ) @@ -158,6 +188,25 @@ export namespace LSP { return state() } + /** Stop every language server for the current project. A generation bump + * also invalidates servers whose spawn/initialize handshake was in flight + * when trust was revoked in this or another server process. */ + export async function dispose() { + const current = await state() + current.generation++ + current.spawning.clear() + // Preserve live ancestry until durable revocation has captured and killed + // direct setsid/start_new_session descendants. + await CredentialProcessLedger.revoke({ kind: "lsp", projectID: current.projectID }) + const processes = [...current.processes] + current.processes.clear() + for (const process of processes) process.kill() + const clients = current.clients.splice(0) + const results = await Promise.allSettled(clients.map((client) => client.shutdown())) + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) log.warn("Language-server client cleanup failed after durable revocation", { failures }) + } + export const Status = z .object({ id: z.string(), @@ -191,7 +240,6 @@ export namespace LSP { const result: LSPClient.Info[] = [] async function active(client: LSPClient.Info) { - if (!client.project) return true try { await ProjectTrust.require(Instance.project, "project_lsp") return true @@ -205,8 +253,47 @@ export namespace LSP { } async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) + const generation = s.generation + // Even a globally installed LSP can execute project-owned config, + // plugins, hooks, or code merely by starting in the project root. + // Binary location is therefore not a safe trust classifier. + const policy = await Config.trustedSandbox() + const handle = await ProjectTrust.require(Instance.project, "project_lsp") + .then(() => + withLSPSandbox( + { + root, + options: policy, + allowArgumentReadDirectories: server.configured !== true, + async register(process, windowsRelease) { + if (!process.pid) throw new Error("Language server started without a process id") + const id = `lsp-${crypto.randomUUID()}` + const registered = await CredentialProcessLedger.register({ + id, + kind: "lsp", + pid: process.pid, + detached: globalThis.process.platform !== "win32", + projectID: s.projectID, + windowsRelease, + }) + if (!registered) { + throw new Error("Language server exited before durable process-group ownership was established") + } + s.processes.add(process) + let completed = false + return () => { + if (completed) return + completed = true + s.processes.delete(process) + void completeProcess(id).catch((error) => + log.error("Failed to complete durable language-server ownership", { error, id }), + ) + } + }, + }, + () => server.spawn(root), + ), + ) .then((value) => { if (!value) s.broken.add(key) return value @@ -222,6 +309,18 @@ export namespace LSP { }) if (!handle) return undefined + handle.project = true + if (generation !== s.generation) { + handle.process.kill() + return undefined + } + try { + await ProjectTrust.require(Instance.project, "project_lsp") + } catch (error) { + handle.process.kill() + if (ProjectTrust.DeniedError.isInstance(error)) return undefined + throw error + } log.info("spawned lsp server", { serverID: server.id }) const client = await LSPClient.create({ @@ -240,6 +339,10 @@ export namespace LSP { return undefined } + if (generation !== s.generation) { + await client.shutdown() + return undefined + } if (!(await active(client))) return undefined const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) diff --git a/backend/cli/src/lsp/server.ts b/backend/cli/src/lsp/server.ts index 8c53b021..d0f6e7cc 100644 --- a/backend/cli/src/lsp/server.ts +++ b/backend/cli/src/lsp/server.ts @@ -1,19 +1,153 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "child_process" +import { spawn as spawnProcess, type ChildProcessWithoutNullStreams } from "child_process" import path from "path" import os from "os" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" -import { $, readableStreamToText } from "bun" +import { $ as bunShell, readableStreamToText } from "bun" import fs from "fs/promises" +import fsSync from "fs" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util/archive" import { ProjectTrust } from "../project/trust" +import { OpenScience } from "../openscience" +import { AsyncLocalStorage } from "node:async_hooks" +import { Sandbox } from "../sandbox/sandbox" +import { AuthoritySignal } from "../project/authority-signal" +import { WindowsJobLauncher } from "../process/windows-job-launcher" + +interface LaunchContext { + root: string + options: Sandbox.Options + /** Built-in server definitions may name trusted support files in argv. A + * configured server's argv is project/user input and must never widen read + * access merely by naming an arbitrary host path. */ + allowArgumentReadDirectories: boolean + register(process: ChildProcessWithoutNullStreams, windowsRelease?: string): Promise<() => void> +} + +const launch = new AsyncLocalStorage() +const environment = (overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv => ({ + ...OpenScience.kernelEnv(process.env), + ...overrides, +}) + +/** + * Bind the final language-server process creation to the project whose LSP + * request is being served. Preparation (binary discovery/downloads) stays + * outside the authority lease; the actual spawn below re-checks trust while + * holding it and registers the child before a revocation can be acknowledged. + */ +export function withLSPSandbox(input: LaunchContext, action: () => Promise): Promise { + return launch.run(input, action) +} + +// child_process.spawn inherits process.env when `env` is omitted. Keep that +// dangerous default out of this module: language servers need toolchain/runtime +// discovery, never account, provider, or cloud credentials. +async function spawn(command: string, argsOrOptions?: any, maybeOptions?: any) { + const context = launch.getStore() + if (!context) throw new Error("Language-server spawn attempted outside its trusted launch context") + + const args: string[] = Array.isArray(argsOrOptions) ? argsOrOptions : [] + const options = (Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions) ?? {} + return AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "project_lsp") + const sandbox = Sandbox.wrapArgv({ + file: command, + args, + workspace: [Instance.directory, Instance.worktree], + readable: [ + context.root, + ...(context.allowArgumentReadDirectories + ? args.flatMap((value) => { + if (!path.isAbsolute(value)) return [] + try { + return [fsSync.statSync(value).isDirectory() ? value : path.dirname(value)] + } catch { + return [] + } + }) + : []), + ], + unreadable: OpenScience.kernelSensitivePaths(), + options: context.options, + }) + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + + let child: ChildProcessWithoutNullStreams + try { + const env = environment(options.env) + if (sandbox.temporary) { + env.HOME = sandbox.temporary + env.XDG_CONFIG_HOME = path.join(sandbox.temporary, "config") + env.XDG_CACHE_HOME = path.join(sandbox.temporary, "cache") + env.XDG_DATA_HOME = path.join(sandbox.temporary, "data") + env.XDG_STATE_HOME = path.join(sandbox.temporary, "state") + } + child = spawnProcess(wrapped.file, wrapped.args, { + ...options, + shell: false, + env, + // A private process group lets durable ownership reap language-server + // helpers and background descendants after this server process dies. + detached: process.platform !== "win32", + }) as ChildProcessWithoutNullStreams + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + + let unregister: (() => void) | undefined + let finished = false + const cleanup = () => { + finished = true + unregister?.() + unregister = undefined + Sandbox.cleanup(sandbox) + } + child.once("exit", cleanup) + child.once("error", cleanup) + try { + // The authority lease remains held until exact process identity and + // project ownership are durably recorded by the caller. + unregister = await context.register(child, wrapped.release) + // A fast child can exit while durable registration is in flight. The + // first cleanup saw no callback; run it again now so ownership is not + // stranded in the ledger. + if (finished) cleanup() + } catch (error) { + child.kill() + cleanup() + throw error + } + return child + }) +} + +export const spawnLSPChild = spawn export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) + + const bunSpawn: typeof Bun.spawn = ((commandOrOptions: any, options?: any) => { + if (Array.isArray(commandOrOptions)) { + return Bun.spawn(commandOrOptions, { + ...options, + env: environment(options?.env), + }) + } + return Bun.spawn({ + ...commandOrOptions, + env: environment(commandOrOptions?.env), + }) + }) as typeof Bun.spawn + + const $: typeof bunShell = ((strings: TemplateStringsArray, ...expressions: any[]) => + bunShell(strings, ...expressions).env(environment())) as typeof bunShell + const pathExists = async (p: string) => fs .stat(p) @@ -57,6 +191,8 @@ export namespace LSPServer { id: string extensions: string[] global?: boolean + /** True when command/argv came from global or project configuration. */ + configured?: boolean root: RootFunction spawn(root: string): Promise } @@ -90,7 +226,7 @@ export namespace LSPServer { } const project = await projectBinary(deno) return { - process: spawn(deno, ["lsp"], { + process: await spawn(deno, ["lsp"], { cwd: root, }), project, @@ -110,10 +246,10 @@ export namespace LSPServer { log.info("typescript server", { tsserver }) if (!tsserver) return const project = await projectBinary(tsserver) - const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { + const proc = await spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -147,10 +283,10 @@ export namespace LSPServer { ) if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], { + await bunSpawn([BunProc.which(), "install", "@vue/language-server"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -163,10 +299,10 @@ export namespace LSPServer { } args.push("--stdio") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -225,10 +361,10 @@ export namespace LSPServer { log.info("installed VS Code ESLint server", { serverPath }) } - const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { + const proc = await spawn(BunProc.which(), [serverPath, "--stdio"], { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -282,12 +418,12 @@ export namespace LSPServer { if (lintBin) { const project = await projectBinary(lintBin) - const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" }) + const proc = bunSpawn([lintBin, "--help"], { stdout: "pipe" }) await proc.exited const help = await readableStreamToText(proc.stdout) if (help.includes("--lsp")) { return { - process: spawn(lintBin, ["--lsp"], { + process: await spawn(lintBin, ["--lsp"], { cwd: root, }), project, @@ -303,7 +439,7 @@ export namespace LSPServer { if (serverBin) { const project = await projectBinary(serverBin) return { - process: spawn(serverBin, [], { + process: await spawn(serverBin, [], { cwd: root, }), project, @@ -365,10 +501,10 @@ export namespace LSPServer { args = ["x", "biome", "lsp-proxy", "--stdio"] } - const proc = spawn(bin, args, { + const proc = await spawn(bin, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -397,9 +533,9 @@ export namespace LSPServer { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return log.info("installing gopls") - const proc = Bun.spawn({ + const proc = bunSpawn({ cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], - env: { ...process.env, GOBIN: Global.Path.bin }, + env: { ...OpenScience.kernelEnv(process.env), GOBIN: Global.Path.bin }, stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -416,7 +552,7 @@ export namespace LSPServer { } const project = await projectBinary(bin!) return { - process: spawn(bin!, { + process: await spawn(bin!, { cwd: root, }), project, @@ -441,7 +577,7 @@ export namespace LSPServer { } if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return log.info("installing rubocop") - const proc = Bun.spawn({ + const proc = bunSpawn({ cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin], stdout: "pipe", stderr: "pipe", @@ -459,7 +595,7 @@ export namespace LSPServer { } const project = await projectBinary(bin!) return { - process: spawn(bin!, ["--lsp"], { + process: await spawn(bin!, ["--lsp"], { cwd: root, }), project, @@ -524,7 +660,7 @@ export namespace LSPServer { } project = (await projectBinary(binary)) || project - const proc = spawn(binary, ["server"], { + const proc = await spawn(binary, ["server"], { cwd: root, }) @@ -547,10 +683,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "pyright"], { + await bunSpawn([BunProc.which(), "install", "pyright"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }).exited @@ -579,10 +715,10 @@ export namespace LSPServer { } project = (await projectBinary(binary)) || project - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -640,7 +776,7 @@ export namespace LSPServer { await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` .quiet() .cwd(path.join(Global.Path.bin, "elixir-ls-master")) - .env({ MIX_ENV: "prod", ...process.env }) + .env({ MIX_ENV: "prod", ...OpenScience.kernelEnv(process.env) }) log.info(`installed elixir-ls`, { path: elixirLsPath, @@ -650,7 +786,7 @@ export namespace LSPServer { const project = await projectBinary(binary) return { - process: spawn(binary, { + process: await spawn(binary, { cwd: root, }), project, @@ -764,7 +900,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -788,7 +924,7 @@ export namespace LSPServer { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return log.info("installing csharp-ls via dotnet tool") - const proc = Bun.spawn({ + const proc = bunSpawn({ cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], stdout: "pipe", stderr: "pipe", @@ -806,7 +942,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -830,7 +966,7 @@ export namespace LSPServer { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return log.info("installing fsautocomplete via dotnet tool") - const proc = Bun.spawn({ + const proc = bunSpawn({ cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], stdout: "pipe", stderr: "pipe", @@ -848,7 +984,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -867,7 +1003,7 @@ export namespace LSPServer { if (sourcekit) { const project = await projectBinary(sourcekit) return { - process: spawn(sourcekit, { + process: await spawn(sourcekit, { cwd: root, }), project, @@ -888,7 +1024,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -936,7 +1072,7 @@ export namespace LSPServer { } const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -954,7 +1090,7 @@ export namespace LSPServer { if (fromPath) { const project = await projectBinary(fromPath) return { - process: spawn(fromPath, args, { + process: await spawn(fromPath, args, { cwd: root, }), project, @@ -966,7 +1102,7 @@ export namespace LSPServer { if (await Bun.file(direct).exists()) { const project = await projectBinary(direct) return { - process: spawn(direct, args, { + process: await spawn(direct, args, { cwd: root, }), project, @@ -981,7 +1117,7 @@ export namespace LSPServer { if (await Bun.file(candidate).exists()) { const project = await projectBinary(candidate) return { - process: spawn(candidate, args, { + process: await spawn(candidate, args, { cwd: root, }), project, @@ -1090,7 +1226,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, args, { + process: await spawn(bin, args, { cwd: root, }), project, @@ -1109,10 +1245,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { + await bunSpawn([BunProc.which(), "install", "svelte-language-server"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1125,10 +1261,10 @@ export namespace LSPServer { } args.push("--stdio") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1159,10 +1295,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { + await bunSpawn([BunProc.which(), "install", "@astrojs/language-server"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1175,10 +1311,10 @@ export namespace LSPServer { } args.push("--stdio") const binaryProject = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1271,7 +1407,7 @@ export namespace LSPServer { ) const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-jdtls-data")) return { - process: spawn( + process: await spawn( java, [ "-jar", @@ -1382,7 +1518,7 @@ export namespace LSPServer { } const project = await projectBinary(launcherScript) return { - process: spawn(launcherScript, ["--stdio"], { + process: await spawn(launcherScript, ["--stdio"], { cwd: root, }), project, @@ -1410,10 +1546,10 @@ export namespace LSPServer { const exists = await Bun.file(js).exists() if (!exists) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], { + await bunSpawn([BunProc.which(), "install", "yaml-language-server"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1426,10 +1562,10 @@ export namespace LSPServer { } args.push("--stdio") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1574,7 +1710,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -1593,10 +1729,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "intelephense"], { + await bunSpawn([BunProc.which(), "install", "intelephense"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1609,10 +1745,10 @@ export namespace LSPServer { } args.push("--stdio") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1640,7 +1776,7 @@ export namespace LSPServer { } const project = await projectBinary(prisma) return { - process: spawn(prisma, ["language-server"], { + process: await spawn(prisma, ["language-server"], { cwd: root, }), project, @@ -1660,7 +1796,7 @@ export namespace LSPServer { } const project = await projectBinary(dart) return { - process: spawn(dart, ["language-server", "--lsp"], { + process: await spawn(dart, ["language-server", "--lsp"], { cwd: root, }), project, @@ -1680,7 +1816,7 @@ export namespace LSPServer { } const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -1698,10 +1834,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "bash-language-server"], { + await bunSpawn([BunProc.which(), "install", "bash-language-server"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1714,10 +1850,10 @@ export namespace LSPServer { } args.push("start") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1806,7 +1942,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, ["serve"], { + process: await spawn(bin, ["serve"], { cwd: root, }), project, @@ -1904,7 +2040,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), project, @@ -1923,10 +2059,10 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENSCIENCE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + await bunSpawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { cwd: Global.Path.bin, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, stdout: "pipe", @@ -1939,10 +2075,10 @@ export namespace LSPServer { } args.push("--stdio") const project = await projectBinary(binary) - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), BUN_BE_BUN: "1", }, }) @@ -1965,7 +2101,7 @@ export namespace LSPServer { } const project = await projectBinary(gleam) return { - process: spawn(gleam, ["lsp"], { + process: await spawn(gleam, ["lsp"], { cwd: root, }), project, @@ -1988,7 +2124,7 @@ export namespace LSPServer { } const project = await projectBinary(bin) return { - process: spawn(bin, ["listen"], { + process: await spawn(bin, ["listen"], { cwd: root, }), project, @@ -2018,10 +2154,10 @@ export namespace LSPServer { } const project = await projectBinary(nixd) return { - process: spawn(nixd, [], { + process: await spawn(nixd, [], { cwd: root, env: { - ...process.env, + ...OpenScience.kernelEnv(process.env), }, }), project, @@ -2119,7 +2255,7 @@ export namespace LSPServer { const project = await projectBinary(bin) return { - process: spawn(bin, { cwd: root }), + process: await spawn(bin, { cwd: root }), project, } }, @@ -2137,7 +2273,7 @@ export namespace LSPServer { } const project = await projectBinary(bin) return { - process: spawn(bin, ["--lsp"], { + process: await spawn(bin, ["--lsp"], { cwd: root, }), project, diff --git a/backend/cli/src/mcp/auth.ts b/backend/cli/src/mcp/auth.ts index f6360503..06e49c81 100644 --- a/backend/cli/src/mcp/auth.ts +++ b/backend/cli/src/mcp/auth.ts @@ -2,6 +2,7 @@ import path from "path" import z from "zod" import { Global } from "../global" import { JsonStore } from "../util/jsonstore" +import { CredentialLifecycle } from "../credentials/lifecycle" export namespace McpAuth { export const Tokens = z.object({ @@ -72,32 +73,51 @@ export namespace McpAuth { export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise { // Always update serverUrl if provided if (serverUrl) entry.serverUrl = serverUrl - await JsonStore.update(filepath, (data) => ({ ...data, [mcpName]: entry })) + await CredentialLifecycle.mutate( + `mcp-auth.set:${mcpName}`, + () => JsonStore.update(filepath, (data) => ({ ...data, [mcpName]: entry })), + { reconcileLocal: false }, + ) } export async function remove(mcpName: string): Promise { - await JsonStore.update(filepath, (data) => { - delete data[mcpName] - }) + await CredentialLifecycle.mutate( + `mcp-auth.remove:${mcpName}`, + () => + JsonStore.update(filepath, (data) => { + delete data[mcpName] + }), + { reconcileLocal: false }, + ) } export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise { - await update( - mcpName, - (entry) => { - entry.tokens = tokens - }, - serverUrl, + await CredentialLifecycle.mutate( + `mcp-auth.tokens:${mcpName}`, + () => + update( + mcpName, + (entry) => { + entry.tokens = tokens + }, + serverUrl, + ), + { reconcileLocal: false }, ) } export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise { - await update( - mcpName, - (entry) => { - entry.clientInfo = clientInfo - }, - serverUrl, + await CredentialLifecycle.mutate( + `mcp-auth.client:${mcpName}`, + () => + update( + mcpName, + (entry) => { + entry.clientInfo = clientInfo + }, + serverUrl, + ), + { reconcileLocal: false }, ) } diff --git a/backend/cli/src/mcp/group-launcher.ts b/backend/cli/src/mcp/group-launcher.ts new file mode 100644 index 00000000..1619710c --- /dev/null +++ b/backend/cli/src/mcp/group-launcher.ts @@ -0,0 +1,110 @@ +import { dlopen, FFIType } from "bun:ffi" +import fs from "node:fs/promises" +import path from "node:path" +import { DarwinResponsibilityLauncher } from "../process/darwin-responsibility-launcher" + +export const GROUP_LAUNCHER_ARG = "__openscience_mcp_group_launcher__" + +export function invocation(input: { + execPath: string + sourceEntry: string + ready: string + file: string + args: string[] +}): { command: string; args: string[]; release?: string } { + if (process.platform === "darwin") { + const wrapped = DarwinResponsibilityLauncher.wrap({ + file: input.file, + args: input.args, + ready: input.ready, + ownSession: true, + }) + return { command: wrapped.file, args: wrapped.args, release: wrapped.release } + } + const executable = path.basename(input.execPath).toLowerCase() + const sourceRuntime = executable === "bun" || executable === "bun.exe" + return { + command: input.execPath, + // A compiled OpenScience executable re-enters its bundled index directly. + // A source checkout must first tell Bun which entrypoint to execute. + args: [...(sourceRuntime ? [input.sourceEntry] : []), GROUP_LAUNCHER_ARG, input.ready, input.file, ...input.args], + ...(process.platform === "win32" ? { release: `${input.ready}.release` } : {}), + } +} + +function systemLibraries(): string[] { + if (process.platform === "darwin") return ["/usr/lib/libSystem.B.dylib"] + if (process.arch === "arm64") { + return ["libc.so.6", "/lib/aarch64-linux-gnu/libc.so.6", "/lib/libc.musl-aarch64.so.1"] + } + return ["libc.so.6", "/lib/x86_64-linux-gnu/libc.so.6", "/lib64/libc.so.6", "/lib/libc.musl-x86_64.so.1"] +} + +export async function run(args: string[]): Promise { + const [ready, file, ...commandArgs] = args + if (!ready || !file) throw new Error("The MCP process-group launcher requires a ready marker and command") + if (process.platform === "win32") { + await fs.writeFile(ready, String(process.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + const release = `${ready}.release` + try { + for (let attempt = 0; attempt < 3_000; attempt++) { + const owner = await fs.readFile(release, "utf8").catch(() => undefined) + if (owner?.trim() === String(process.pid)) break + if (attempt === 2_999) throw new Error("Timed out waiting for Windows Job Object ownership") + await Bun.sleep(10) + } + const child = Bun.spawn([file, ...commandArgs], { + cwd: process.cwd(), + env: process.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + windowsHide: true, + }) + return child.exited + } finally { + await fs.rm(release, { force: true }).catch(() => undefined) + } + } + + let libc: ReturnType | undefined + let lastError: unknown + for (const library of systemLibraries()) { + try { + libc = dlopen(library, { setsid: { args: [], returns: FFIType.i32 } }) + break + } catch (error) { + lastError = error + } + } + if (!libc) throw lastError ?? new Error("Could not load the host C library for setsid()") + const setsid = libc.symbols.setsid as unknown as () => number + const session = setsid() + libc.close() + if (session !== process.pid) { + throw new Error(`Could not establish an owned MCP process group (setsid returned ${session})`) + } + await fs.writeFile(ready, String(process.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + + const child = Bun.spawn([file, ...commandArgs], { + cwd: process.cwd(), + env: process.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + + const forward = (signal: NodeJS.Signals) => { + process.removeAllListeners(signal) + try { + process.kill(-process.pid, signal) + } catch { + child.kill(signal) + } + } + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) { + process.on(signal, () => forward(signal)) + } + + return child.exited +} diff --git a/backend/cli/src/mcp/index.ts b/backend/cli/src/mcp/index.ts index 255734ee..598d6788 100644 --- a/backend/cli/src/mcp/index.ts +++ b/backend/cli/src/mcp/index.ts @@ -23,10 +23,30 @@ import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import open from "open" import { OpenScience } from "@/openscience" +import { CredentialProcessLedger } from "@/credentials/process-ledger" +import { ProjectTrust } from "@/project/trust" +import { AuthoritySignal } from "@/project/authority-signal" +import { Sandbox } from "@/sandbox/sandbox" +import fs from "node:fs" +import fsp from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { invocation as groupLauncherInvocation } from "./group-launcher" export namespace MCP { const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 + const CLI_ENTRY = fileURLToPath(new URL("../index.ts", import.meta.url)) + + async function waitForOwnedGroup(marker: string, pid: number): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + const owner = await fsp.readFile(marker, "utf8").catch(() => undefined) + if (owner?.trim() === String(pid)) return + await Bun.sleep(10) + } + throw new Error(`Local MCP process ${pid} did not establish an owned process group`) + } export const Resource = z .object({ @@ -62,6 +82,49 @@ export namespace MCP { ) type MCPClient = Client + const credentialProcesses = new WeakMap() + const localClients = new WeakSet() + const localSandboxes = new WeakMap() + + async function closeClient(client: MCPClient): Promise { + const id = credentialProcesses.get(client) + try { + // Enumerate and revoke while the owned launcher is still alive. Closing + // stdio first would let a direct setsid child reparent outside both the + // leader's descendant closure and its original process group. + if (id && localClients.has(client)) await CredentialProcessLedger.revoke({ id, kind: "mcp" }) + await client.close() + } finally { + if (id) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await CredentialProcessLedger.complete(id)) break + await Bun.sleep(20) + } + credentialProcesses.delete(client) + } + const sandbox = localSandboxes.get(client) + if (sandbox) { + Sandbox.cleanup(sandbox) + localSandboxes.delete(client) + } + localClients.delete(client) + } + } + + /** Stop project-controlled local transports without disturbing remote MCP + * connections. Trust revocation also reaps dead-owner transports through the + * durable credential-process ledger in ProjectBootstrap. */ + export async function disposeLocal(): Promise { + const current = await state() + const local = Object.entries(current.clients).filter(([, client]) => localClients.has(client)) + const results = await Promise.allSettled(local.map(([, client]) => closeClient(client))) + for (const [name] of local) { + delete current.clients[name] + current.status[name] = { status: "disabled" } + } + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) throw new AggregateError(failures, "Local MCP servers could not be stopped") + } export const Status = z .discriminatedUnion("status", [ @@ -150,7 +213,12 @@ export namespace MCP { } // Convert MCP tool definition to AI SDK Tool type - async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise { + async function convertMcpTool( + mcpTool: MCPToolDef, + client: MCPClient, + timeout?: number, + projectOwned = false, + ): Promise { const inputSchema = mcpTool.inputSchema // Spread first, then override type to ensure it's always "object" @@ -165,6 +233,7 @@ export namespace MCP { description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { + if (projectOwned) await ProjectTrust.require(Instance.project, "project_mcp") return client.callTool( { name: mcpTool.name, @@ -205,6 +274,72 @@ export namespace MCP { } } + function localReadRoots(values: string[], cwd: string): string[] { + const roots = new Set([cwd]) + const dependencies = (modules: string) => { + const queue = fs + .readdirSync(modules, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .flatMap((entry) => { + const candidate = path.join(modules, entry.name) + if (!entry.name.startsWith("@")) return [candidate] + return fs + .readdirSync(candidate, { withFileTypes: true }) + .filter((child) => child.isDirectory() || child.isSymbolicLink()) + .map((child) => path.join(candidate, child.name)) + }) + for (const candidate of queue) { + const real = (() => { + try { + return fs.realpathSync.native(candidate) + } catch { + return undefined + } + })() + if (!real) continue + const stores = [ + `${path.sep}node_modules${path.sep}.bun${path.sep}`, + `${path.sep}node_modules${path.sep}.pnpm${path.sep}`, + ] + const marker = stores.find((value) => real.includes(value)) + if (marker) { + roots.add(real.slice(0, real.indexOf(marker) + marker.length - 1)) + return + } + roots.add(real) + } + } + for (const value of values) { + if (!path.isAbsolute(value)) continue + const start = (() => { + try { + return fs.statSync(value).isDirectory() ? value : path.dirname(value) + } catch { + return path.dirname(value) + } + })() + let cursor = start + while (true) { + if (fs.existsSync(path.join(cursor, "package.json"))) { + roots.add(cursor) + const modules = path.join(cursor, "node_modules") + if (fs.existsSync(modules)) { + roots.add(modules) + dependencies(modules) + } + break + } + const parent = path.dirname(cursor) + if (parent === cursor) { + roots.add(start) + break + } + cursor = parent + } + } + return [...roots] + } + const state = Instance.state( async () => { const cfg = await Config.getExecution() @@ -243,7 +378,7 @@ export namespace MCP { async (state) => { await Promise.all( Object.values(state.clients).map((client) => - client.close().catch((error) => { + closeClient(client).catch((error) => { log.error("Failed to close MCP client", { error, }) @@ -315,7 +450,7 @@ export namespace MCP { if (!result.mcpClient) { const existingClient = s.clients[name] if (existingClient) { - await existingClient.close().catch((error) => { + await closeClient(existingClient).catch((error) => { log.error("Failed to close existing MCP client", { name, error }) }) delete s.clients[name] @@ -328,7 +463,7 @@ export namespace MCP { // Close existing client if present to prevent memory leaks const existingClient = s.clients[name] if (existingClient) { - await existingClient.close().catch((error) => { + await closeClient(existingClient).catch((error) => { log.error("Failed to close existing MCP client", { name, error }) }) } @@ -456,25 +591,90 @@ export namespace MCP { if (mcp.type === "local") { const [cmd, ...args] = mcp.command const cwd = Instance.directory - const env = localEnv(await OpenScience.subprocessEnv(process.env), cmd, mcp.environment) - const transport = new StdioClientTransport({ - stderr: "pipe", - command: cmd, - args, - cwd, - env, - }) - transport.stderr?.on("data", (chunk: Buffer) => { - log.info(`mcp stderr: ${OpenScience.redactSecrets(chunk.toString())}`, { key }) - }) - const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT try { - const client = new Client({ - name: "openscience", - version: Installation.VERSION, + const launched = await AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "project_mcp") + const options = await Config.trustedSandbox() + const sandbox = Sandbox.wrapArgv({ + file: cmd, + args, + workspace: [Instance.directory, Instance.worktree], + readable: localReadRoots(args, cwd), + unreadable: OpenScience.kernelSensitivePaths(), + options, + }) + return OpenScience.withSubprocessEnv(process.env, async (base) => { + const ready = path.join(os.tmpdir(), `openscience-mcp-group-${process.pid}-${crypto.randomUUID()}`) + const launcher = groupLauncherInvocation({ + execPath: process.execPath, + sourceEntry: CLI_ENTRY, + ready, + file: sandbox.file, + args: sandbox.args, + }) + const transport = new StdioClientTransport({ + stderr: "pipe", + // The SDK transport does not expose child_process.detached. + // Launch through a tiny trusted proxy that calls setsid(), then + // keeps the sandboxed server and its ordinary descendants in a + // dedicated, durably reapable process group. + command: launcher.command, + args: launcher.args, + cwd, + env: localEnv(base, sandbox.file, mcp.environment), + }) + transport.stderr?.on("data", (chunk: Buffer) => { + log.info(`mcp stderr: ${OpenScience.redactSecrets(chunk.toString())}`, { key }) + }) + const client = new Client({ + name: "openscience", + version: Installation.VERSION, + }) + try { + // Start and durably register the process before releasing either + // the authority or credential-mutation lease. Client.connect() + // normally starts the SDK transport itself, so replace that + // second start with a no-op after the owned first start. + await withTimeout(transport.start(), connectTimeout) + const pid = transport.pid + if (!pid) throw new Error("Local MCP transport started without a process id") + await withTimeout(waitForOwnedGroup(ready, pid), connectTimeout) + const id = `mcp-${crypto.randomUUID()}` + const registered = await CredentialProcessLedger.register({ + id, + kind: "mcp", + pid, + detached: true, + projectID: Instance.project.id, + windowsRelease: launcher.release, + }) + if (!registered) throw new Error("Local MCP transport exited before durable registration") + credentialProcesses.set(client, id) + localClients.add(client) + localSandboxes.set(client, sandbox) + transport.start = async () => undefined + return { client, transport } + } catch (error) { + Sandbox.cleanup(sandbox) + await transport.close().catch(() => undefined) + throw error + } finally { + await fsp.rm(ready, { force: true }).catch(() => undefined) + await fsp.rm(`${ready}.release`, { force: true }).catch(() => undefined) + if (launcher.release && launcher.release !== `${ready}.release`) { + await fsp.rm(launcher.release, { force: true }).catch(() => undefined) + } + } + }) }) - await withTimeout(client.connect(transport), connectTimeout) + const client = launched.client + try { + await withTimeout(client.connect(launched.transport), connectTimeout) + } catch (error) { + await closeClient(client).catch(() => launched.transport.close().catch(() => undefined)) + throw error + } registerNotificationHandlers(client, key) mcpClient = client status = { @@ -513,7 +713,7 @@ export namespace MCP { return undefined }) if (!result) { - await mcpClient.close().catch((error) => { + await closeClient(mcpClient).catch((error) => { log.error("Failed to close MCP client", { error, }) @@ -617,7 +817,13 @@ export namespace MCP { } export async function clients() { - return state().then((state) => state.clients) + const [current, cfg] = await Promise.all([state(), Config.getExecution()]) + const allowed = new Set( + Object.entries(cfg.mcp ?? {}) + .filter(([, entry]) => isMcpConfigured(entry) && entry.enabled !== false) + .map(([name]) => name), + ) + return Object.fromEntries(Object.entries(current.clients).filter(([name]) => allowed.has(name))) } export async function connect(name: string) { @@ -651,7 +857,7 @@ export namespace MCP { // Close existing client if present to prevent memory leaks const existingClient = s.clients[name] if (existingClient) { - await existingClient.close().catch((error) => { + await closeClient(existingClient).catch((error) => { log.error("Failed to close existing MCP client", { name, error }) }) } @@ -663,7 +869,7 @@ export namespace MCP { const s = await state() const client = s.clients[name] if (client) { - await client.close().catch((error) => { + await closeClient(client).catch((error) => { log.error("Failed to close MCP client", { name, error }) }) delete s.clients[name] @@ -677,7 +883,7 @@ export namespace MCP { const s = await state() const client = s.clients[name] if (client) { - await client.close().catch((error) => { + await closeClient(client).catch((error) => { log.error("Failed to close MCP client", { name, error }) }) delete s.clients[name] @@ -715,10 +921,16 @@ export namespace MCP { const mcpConfig = config[clientName] const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined const timeout = entry?.timeout ?? defaultTimeout + const projectOwned = await Config.projectControlsMcp(clientName) for (const mcpTool of toolsResult.tools) { const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_") - result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout) + result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool( + mcpTool, + client, + timeout, + projectOwned, + ) } } return result @@ -767,6 +979,9 @@ export namespace MCP { } export async function getPrompt(clientName: string, name: string, args?: Record) { + if (await Config.projectControlsMcp(clientName)) { + await ProjectTrust.require(Instance.project, "project_mcp") + } const clientsSnapshot = await clients() const client = clientsSnapshot[clientName] @@ -795,6 +1010,9 @@ export namespace MCP { } export async function readResource(clientName: string, resourceUri: string) { + if (await Config.projectControlsMcp(clientName)) { + await ProjectTrust.require(Instance.project, "project_mcp") + } const clientsSnapshot = await clients() const client = clientsSnapshot[clientName] diff --git a/backend/cli/src/openscience/dotenv.ts b/backend/cli/src/openscience/dotenv.ts index f47c8d27..9c581747 100644 --- a/backend/cli/src/openscience/dotenv.ts +++ b/backend/cli/src/openscience/dotenv.ts @@ -3,16 +3,14 @@ * * The shipped binary builds with `autoloadDotenv: false` (script/build.ts) so it * never silently ingests an ambient `.env` from whatever directory it is run in. - * But a user's own project `.env` is a first-class BYOK source — the same as a - * shell export or `keys add`. So we load it ourselves, explicitly and - * predictably, from the launch directory. + * Repository `.env` files are never loaded during OpenScience boot: canonical + * project trust does not exist at that import boundary. This parser/loader is + * retained for explicit post-trust workload use and tests; callers must not use + * it as a host credential/control-plane source. * - * Precedence: a real shell export always wins (we only apply vars that are not - * already set), and because preload-env.ts calls this BEFORE replaying the - * synced-env snapshot, a `.env` key also wins over a managed synced value — - * matching the "the user's own key beats the managed wallet" rule everywhere - * else. A `.env` is the user's own credential, so it is NOT subject to the sync - * blocklist (synced-env-policy.ts) — that only filters Atlas-provided values. + * Precedence for an explicit caller: a real shell export always wins. Even + * after trust, OpenScience control-plane, loader, proxy, and provider-routing + * variables remain explicit shell/global settings. * * Kept dependency-free (only node fs/path) so preload-env.ts can call it at * module init before the rest of the app loads. @@ -56,15 +54,109 @@ export function parseDotenv(raw: string): Array<[string, string]> { * code into the tool subprocesses openscience spawns. A shell export of these * still works; only the `.env` path is refused. */ const DANGEROUS_ENV = new Set([ + // OpenScience/host process discovery and import behavior. + "PATH", + "HOME", + "SHELL", + "ENV", + "BASH_ENV", + "ZDOTDIR", + "CDPATH", + "IFS", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", "NODE_OPTIONS", "BUN_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", + "PYTHONPATH", + "PYTHONHOME", + "RUBYOPT", + "RUBYLIB", + "PERL5OPT", + "PERL5LIB", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "CLASSPATH", + "BUNDLE_GEMFILE", + "GIT_ASKPASS", + "GIT_SSH_COMMAND", + "SSH_ASKPASS", "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", + // Transport indirection can redirect a shell-exported credential to an + // attacker-controlled proxy/CA even when the credential itself is not in + // the repository. + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "ATLAS_BASE_URL", + "ANTHROPIC_BASE_URL", + "OPENAI_BASE_URL", + "GOOGLE_GENERATIVE_AI_BASE_URL", + "GOOGLE_BASE_URL", + "GEMINI_BASE_URL", + "OPENROUTER_BASE_URL", + "META_MODEL_BASE_URL", + "TOGETHER_BASE_URL", + "GROQ_BASE_URL", + "FIREWORKS_BASE_URL", + "XAI_BASE_URL", + "MISTRAL_BASE_URL", + "DEEPSEEK_BASE_URL", + "CEREBRAS_BASE_URL", + "PERPLEXITY_BASE_URL", + "AZURE_OPENAI_ENDPOINT", + "TINKER_BASE_URL", ]) +/** Repository dotenv is data/workload configuration, never an authority to + * reconfigure the OpenScience host. This predicate runs before Flag, Config, + * Global, provider SDK, and plugin modules are imported. */ +export function isProjectDotenvAllowed(key: string): boolean { + if (DANGEROUS_ENV.has(key)) return false + if (key.startsWith("OPENSCIENCE_") || key.startsWith("SYNSC_")) return false + if (key.startsWith("GIT_CONFIG_") || key.startsWith("NPM_CONFIG_")) return false + return true +} + +/** Remove variables Bun may have auto-loaded from the launch directory before + * JavaScript got control. The standalone binary disables autoload at build + * time, and dev scripts pass --no-env-file, but this closes direct + * `bun src/index.ts` launches too. A parent-shell value that differs from the + * repository value is preserved; an indistinguishable equal value is dropped + * fail-closed and can be supplied through global Keys settings instead. */ +export function scrubAmbientProjectDotenv(cwd: string, env: NodeJS.ProcessEnv): string[] { + const removed: string[] = [] + for (const name of [".env.local", ".env"]) { + let raw: string + try { + raw = fs.readFileSync(path.join(cwd, name), "utf-8") + } catch { + continue + } + for (const [key, value] of parseDotenv(raw)) { + if (value === "" || env[key] !== value) continue + delete env[key] + removed.push(key) + } + } + return [...new Set(removed)] +} + /** Load `.env.local` then `.env` from `cwd`, applying a var only when it is not * already set in `env` (so a shell export wins). `.env.local` is read first so * it takes precedence over `.env` under the "first writer wins" rule. Skips @@ -80,7 +172,7 @@ export function loadProjectDotenv(cwd: string, env: NodeJS.ProcessEnv): string[] continue } for (const [key, value] of parseDotenv(raw)) { - if (DANGEROUS_ENV.has(key)) continue + if (!isProjectDotenvAllowed(key)) continue // Skip empty values: they aren't a real credential, and applying "" here // only to have the synced replay (which treats "" as unset) overwrite it // would violate the shell > .env > synced precedence. diff --git a/backend/cli/src/openscience/index.ts b/backend/cli/src/openscience/index.ts index 870d9b55..1e326705 100644 --- a/backend/cli/src/openscience/index.ts +++ b/backend/cli/src/openscience/index.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { existsSync, readFileSync, writeFileSync, chmodSync } from "fs" import { randomUUID, createHash } from "crypto" import { Global } from "../global" +import { DataRootBarrier } from "../global/data-root-barrier" import { Log } from "../util/log" import { Lock } from "../util/lock" import { Env } from "../env" @@ -16,6 +17,7 @@ import { } from "./synced-env-policy" import { resolveAtlasPackageDir } from "./atlas-package" import { DEFAULT_MANAGED_API_BASE, MANAGED_API_BASE } from "../endpoints" +import { CredentialLifecycle } from "../credentials/lifecycle" const log = Log.create({ service: "openscience" }) @@ -32,16 +34,6 @@ export const API_BASE = MANAGED_API_BASE // (NO_COLOR / TERM=dumb / piped output → plain text) and (b) only // renders when both stdout AND stderr are TTYs. Piping to a log file // no longer drops a one-line dev banner into structured output. -if (API_BASE !== DEFAULT_API_BASE) { - log.info("openscience.api_base.override", { api_base: API_BASE }) - if (process.stderr.isTTY) { - const { UI } = require("../cli/ui") as typeof import("../cli/ui") - process.stderr.write( - `${UI.Style.TEXT_DIM}[openscience] API base: ${API_BASE} (override via SYNSC_API_BASE)${UI.Style.TEXT_NORMAL}\n`, - ) - } -} - // User-facing URL the CLI prints during `openscience login`. Defaults // to the unified Atlas frontend's /cli route — Plan tab, key management, // and billing all live there. SYNSC_AUTH_URL overrides (e.g. point at a @@ -294,6 +286,20 @@ function withAtlasOnPath(env: Record): Record { } export namespace OpenScience { + /** Report a non-production API override after the CLI has initialized its + * log sink. Keeping this out of module initialization is important: runtime + * launchers and library consumers import OpenScience inside child processes, + * and import-time diagnostics would become command stderr or provenance. */ + export function reportApiBaseOverride(): void { + if (API_BASE === DEFAULT_API_BASE) return + log.info("openscience.api_base.override", { api_base: API_BASE }) + if (!process.stderr.isTTY) return + const { UI } = require("../cli/ui") as typeof import("../cli/ui") + process.stderr.write( + `${UI.Style.TEXT_DIM}[openscience] API base: ${API_BASE} (override via SYNSC_API_BASE)${UI.Style.TEXT_NORMAL}\n`, + ) + } + const filepath = path.join(Global.Path.data, "openscience-session.json") /** Friendly device label sent to the backend. Surfaced in the @@ -369,13 +375,17 @@ export namespace OpenScience { } } - export async function saveSession(session: OpenScienceSession) { + async function writeSession(session: OpenScienceSession) { // Atomic temp+rename so a crash or a concurrent reader never sees a torn // session file (which getSession would mis-read as a logout). await atomicWrite(filepath, JSON.stringify(session, null, 2), { mode: 0o600 }) await ensureAtlasCliConfig(session) } + export async function saveSession(session: OpenScienceSession) { + await CredentialLifecycle.mutate("managed-session.set", () => writeSession(session)) + } + /** * Seed the bundled `atlas` CLI's own config (`~/.config/atlas-cli/config.json`) * from the OpenScience session so the agent can run native `atlas` commands. The @@ -402,7 +412,7 @@ export namespace OpenScience { } const next = { ...existing, active_profile: existing.active_profile ?? "default", profiles } await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 }) - await fs.writeFile(configPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 }) + await atomicWrite(configPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 }) } catch (e) { log.warn("could not seed atlas-cli config", { error: e instanceof Error ? e.message : String(e) }) } @@ -414,10 +424,15 @@ export namespace OpenScience { * update racing a background cached_v update) can't lose each other's field * in the read-modify-write. */ async function updateSession(patch: Partial): Promise { - using _ = await Lock.write(filepath) - const session = await getSession() - if (!session) return - await saveSession({ ...session, ...patch }) + await CredentialLifecycle.serialized(async () => { + using _ = await Lock.write(filepath) + const session = await getSession() + if (!session) return + // Sync bookkeeping is not credential material; publishing a credential + // revision for every TTL timestamp would unnecessarily stop live children. + // Do not rewrite the Atlas credential mirror for a timestamp-only patch. + await atomicWrite(filepath, JSON.stringify({ ...session, ...patch }, null, 2), { mode: 0o600 }) + }) } /** TTL gate for the cheap version probe. */ @@ -503,6 +518,31 @@ export namespace OpenScience { } } + /** Reconcile this process with the credential snapshot another server wrote. */ + export async function reloadSyncedEnv(): Promise { + const fresh = await readSyncedSnapshot() + const previous = new Map(syncedSecretValues) + for (const [key, value] of previous.entries()) { + if (fresh.has(key)) continue + unsetSyncedVar(key, value) + } + syncedSecretValues.clear() + for (const [key, value] of fresh.entries()) { + if (!isSyncedEnvAllowed(key, value)) continue + const current = process.env[key] + const ownsSlot = !current || previous.get(key) === current || current === value + if (ownsSlot) { + process.env[key] = value + try { + Env.set(key, value) + } catch { + /* Instance not initialized */ + } + } + syncedSecretValues.set(key, value) + } + } + /** Clear the api_key this CLI seeded into the bundled atlas CLI's config * (see ensureAtlasCliConfig). Only removes the key when it is the one the * session seeded (or, with no readable session, when the profile points at @@ -522,7 +562,7 @@ export namespace OpenScience { const seeded = session?.api_key ? record.api_key === session.api_key : record.base_url === `${API_BASE}/api/v1` if (!seeded) return delete record.api_key - await fs.writeFile(configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }) + await atomicWrite(configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }) } catch { /* missing/unreadable config — nothing to clear */ } @@ -550,30 +590,32 @@ export namespace OpenScience { * logout and the 401-triggered clear. Best-effort; never throws. */ export async function clearSession() { - const session = await getSession() - // Remove the synced credential artifacts FIRST, then delete the session file - // LAST. A crash after unlinking the session but before removing - // synced-env.json would otherwise leave preload-env.ts replaying the managed - // key into process.env on the next boot — the signed-out account's wallet - // kept being debited, the exact thing this function exists to prevent. - // Union of what this process synced (in-memory map) and what the last - // sync persisted (disk snapshot, replayed by preload-env.ts at boot) — - // a fresh `logout` process has only the latter. - const synced = await readSyncedSnapshot() - for (const [key, value] of syncedSecretValues.entries()) synced.set(key, value) - for (const name of ["synced-env.json", "openscience-synced.json", syncedGcpFilename]) { + await CredentialLifecycle.mutate("managed-session.clear", async () => { + const session = await getSession() + // Remove the synced credential artifacts FIRST, then delete the session file + // LAST. A crash after unlinking the session but before removing + // synced-env.json would otherwise leave preload-env.ts replaying the managed + // key into process.env on the next boot — the signed-out account's wallet + // kept being debited, the exact thing this function exists to prevent. + // Union of what this process synced (in-memory map) and what the last + // sync persisted (disk snapshot, replayed by preload-env.ts at boot) — + // a fresh `logout` process has only the latter. + const synced = await readSyncedSnapshot() + for (const [key, value] of syncedSecretValues.entries()) synced.set(key, value) + for (const name of ["synced-env.json", "openscience-synced.json", syncedGcpFilename]) { + try { + await fs.unlink(path.join(getSyncedConfigDir(), name)) + } catch {} + } + for (const [key, value] of synced.entries()) unsetSyncedVar(key, value) + syncedSecretValues.clear() + await clearAtlasCliConfig(session) + await dropUsageQueue() + // Session file last, once the managed-key-replaying artifacts are gone. try { - await fs.unlink(path.join(getSyncedConfigDir(), name)) + await fs.unlink(filepath) } catch {} - } - for (const [key, value] of synced.entries()) unsetSyncedVar(key, value) - syncedSecretValues.clear() - await clearAtlasCliConfig(session) - await dropUsageQueue() - // Session file last, once the managed-key-replaying artifacts are gone. - try { - await fs.unlink(filepath) - } catch {} + }) } /** @@ -766,14 +808,30 @@ export namespace OpenScience { * a torn openscience-synced.json throws during config load and bricks the CLI * until it's removed by hand. */ async function atomicWrite(filepath: string, content: string, options?: { mode?: number }): Promise { + await using operation = await DataRootBarrier.enter(filepath) // Unique per call (not just per PID): two concurrent syncs in the SAME // process (e.g. a per-request /sync and the processor's background sync) // would otherwise write the identical temp path, interleave, and publish a // torn file or fail the rename. const tmp = `${filepath}.${process.pid}.${randomUUID()}.tmp` - await Bun.write(tmp, content, options) - if (options?.mode !== undefined && process.platform !== "win32") await fs.chmod(tmp, options.mode) - await fs.rename(tmp, filepath) + await fs.mkdir(path.dirname(filepath), { recursive: true }) + try { + const handle = await fs.open(tmp, "wx", options?.mode ?? 0o600) + await handle + .writeFile(content, "utf8") + .then(() => + options?.mode !== undefined && process.platform !== "win32" ? handle.chmod(options.mode) : undefined, + ) + .then(() => handle.sync()) + .finally(() => handle.close()) + await fs.rename(tmp, filepath) + const directory = await fs.open(path.dirname(filepath), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) + } catch (error) { + await fs.rm(tmp, { force: true }).catch(() => undefined) + throw error + } } /** Fetch all connected service credentials and inject as env vars */ @@ -784,10 +842,6 @@ export namespace OpenScience { const session = await getSession() if (!session) return null - // Keep the bundled atlas CLI authenticated for the agent on every startup - // sync (covers existing sessions that never re-run saveSession). - await ensureAtlasCliConfig(session) - try { const res = await atlasFetch(`${API_BASE}/api/cli/sync`, { headers: { Authorization: `Bearer ${session.api_key}` }, @@ -835,146 +889,156 @@ export namespace OpenScience { } } - // Atlas transfers a GCP service-account document as an in-memory secret. - // Materialize it to an owner-only file before persistence so Google SDKs - // receive their standard GOOGLE_APPLICATION_CREDENTIALS path and the JSON - // never enters an agent shell. - const gcp = fresh.get("GOOGLE_APPLICATION_CREDENTIALS_JSON") - const gcpFile = path.join(getSyncedConfigDir(), syncedGcpFilename) - if (gcp) { - fresh.delete("GOOGLE_APPLICATION_CREDENTIALS_JSON") - const dir = getSyncedConfigDir() - const saved = await fs - .mkdir(dir, { recursive: true }) - .then(() => atomicWrite(gcpFile, gcp, { mode: 0o600 })) - .then(() => true) - .catch((error) => { - log.warn("failed to materialize synced GCP credentials", { - error: error instanceof Error ? error.message : String(error), + return await CredentialLifecycle.mutate("managed-services.sync", async () => { + const current = await getSession() + if (!current || current.api_key !== session.api_key) { + throw new Error("Managed session changed while services were syncing; discarded the stale response") + } + // Keep the bundled atlas CLI authenticated for the agent on every + // successful sync (covers existing sessions that never re-run login). + await ensureAtlasCliConfig(session) + + // Atlas transfers a GCP service-account document as an in-memory secret. + // Materialize it to an owner-only file before persistence so Google SDKs + // receive their standard GOOGLE_APPLICATION_CREDENTIALS path and the JSON + // never enters an agent shell. + const gcp = fresh.get("GOOGLE_APPLICATION_CREDENTIALS_JSON") + const gcpFile = path.join(getSyncedConfigDir(), syncedGcpFilename) + if (gcp) { + fresh.delete("GOOGLE_APPLICATION_CREDENTIALS_JSON") + const dir = getSyncedConfigDir() + const saved = await fs + .mkdir(dir, { recursive: true }) + .then(() => atomicWrite(gcpFile, gcp, { mode: 0o600 })) + .then(() => true) + .catch((error) => { + log.warn("failed to materialize synced GCP credentials", { + error: error instanceof Error ? error.message : String(error), + }) + return false }) - return false - }) - if (saved) fresh.set("GOOGLE_APPLICATION_CREDENTIALS", gcpFile) - if (!saved) await fs.unlink(gcpFile).catch(() => {}) - } - if (!gcp) await fs.unlink(gcpFile).catch(() => {}) + if (saved) fresh.set("GOOGLE_APPLICATION_CREDENTIALS", gcpFile) + if (!saved) await fs.unlink(gcpFile).catch(() => {}) + } + if (!gcp) await fs.unlink(gcpFile).catch(() => {}) - // Keep user-owned provider keys and the narrow OpenRouter managed route. - // The policy rejects direct-provider proxy tokens and untrusted provider - // base URLs before anything is applied or persisted. - for (const [key, value] of [...fresh.entries()]) { - if (!isSyncedEnvAllowed(key, value)) fresh.delete(key) - } + // Keep user-owned provider keys and the narrow OpenRouter managed route. + // The policy rejects direct-provider proxy tokens and untrusted provider + // base URLs before anything is applied or persisted. + for (const [key, value] of [...fresh.entries()]) { + if (!isSyncedEnvAllowed(key, value)) fresh.delete(key) + } - // Older Atlas sync responses can carry only OPENROUTER_API_KEY=thk_*. - // Managed OpenRouter must also carry the Atlas proxy baseURL; otherwise - // provider init correctly refuses to send a wallet token to public - // openrouter.ai and the UI shows ProviderInitError. - const openrouterKey = fresh.get("OPENROUTER_API_KEY") - if (isManagedAtlasKey(openrouterKey ?? "") && !fresh.has("OPENROUTER_BASE_URL")) { - fresh.set("OPENROUTER_BASE_URL", managedOpenRouterBaseURL()) - } + // Older Atlas sync responses can carry only OPENROUTER_API_KEY=thk_*. + // Managed OpenRouter must also carry the Atlas proxy baseURL; otherwise + // provider init correctly refuses to send a wallet token to public + // openrouter.ai and the UI shows ProviderInitError. + const openrouterKey = fresh.get("OPENROUTER_API_KEY") + if (isManagedAtlasKey(openrouterKey ?? "") && !fresh.has("OPENROUTER_BASE_URL")) { + fresh.set("OPENROUTER_BASE_URL", managedOpenRouterBaseURL()) + } - // Count distinct APPLIED credential values (post-filter, ignoring routing - // *_BASE_URL vars) so the returned total reflects what the CLI honours — - // never the credentials that were dropped above. - const credentials = new Set( - [...fresh.entries()].filter(([key]) => !key.endsWith("_BASE_URL")).map(([, value]) => value), - ).size - - // Unset previously-synced vars that are absent from the new response — - // mirrors the ownedKeys cleanup in server/routes/settings/credentials.ts. - // "Previously synced" is the union of this process's map and the on-disk - // snapshot preload-env.ts replayed at boot; a var is only removed when - // its live value still matches, so shell exports survive. - const previous = await readSyncedSnapshot() - for (const [key, value] of syncedSecretValues.entries()) previous.set(key, value) - for (const [key, value] of previous.entries()) { - if (fresh.has(key)) continue - unsetSyncedVar(key, value) - } - syncedSecretValues.clear() - for (const [key, value] of fresh.entries()) { - // Respect precedence: never clobber a user's own shell export or BYOK - // value. Only write the synced value when the slot is empty or already - // holds a previously-synced value — mirroring preload-env.ts's "shell - // exports win". Without this, a background sync could overwrite an - // exported ANTHROPIC_API_KEY with a managed thk_ key mid-session, - // silently turning a free BYOK call into a billed managed one. - const current = process.env[key] - const ownsSlot = !current || previous.get(key) === current || current === value - if (ownsSlot) { + // Count distinct APPLIED credential values (post-filter, ignoring routing + // *_BASE_URL vars) so the returned total reflects what the CLI honours — + // never the credentials that were dropped above. + const credentials = new Set( + [...fresh.entries()].filter(([key]) => !key.endsWith("_BASE_URL")).map(([, value]) => value), + ).size + + // Unset previously-synced vars that are absent from the new response — + // mirrors the ownedKeys cleanup in server/routes/settings/credentials.ts. + // "Previously synced" is the union of this process's map and the on-disk + // snapshot preload-env.ts replayed at boot; a var is only removed when + // its live value still matches, so shell exports survive. + const previous = await readSyncedSnapshot() + for (const [key, value] of syncedSecretValues.entries()) previous.set(key, value) + for (const [key, value] of previous.entries()) { + if (fresh.has(key)) continue + unsetSyncedVar(key, value) + } + syncedSecretValues.clear() + for (const [key, value] of fresh.entries()) { + // Respect precedence: never clobber a user's own shell export or BYOK + // value. Only write the synced value when the slot is empty or already + // holds a previously-synced value — mirroring preload-env.ts's "shell + // exports win". Without this, a background sync could overwrite an + // exported ANTHROPIC_API_KEY with a managed thk_ key mid-session, + // silently turning a free BYOK call into a billed managed one. + const current = process.env[key] + const ownsSlot = !current || previous.get(key) === current || current === value + if (ownsSlot) { + try { + Env.set(key, value) + } catch { + /* Instance not initialized */ + } + process.env[key] = value + } + // Track the synced value regardless (for redaction + later cleanup). A + // shadowing shell export is left untouched by the unset pass above, which + // only removes a var whose live value still equals the synced one. + syncedSecretValues.set(key, value) + } + + // Write model lockdown config to managed config dir (highest priority config layer) + if (data.config) { try { - Env.set(key, value) - } catch { - /* Instance not initialized */ + const managedDir = getSyncedConfigDir() + await fs.mkdir(managedDir, { recursive: true }) + await atomicWrite( + path.join(managedDir, "openscience-synced.json"), + JSON.stringify({ $schema: "https://syntheticsciences.ai/config.json", ...data.config }, null, 2), + { mode: 0o600 }, + ) + log.info("wrote managed config", { dir: managedDir }) + } catch (e) { + log.warn("failed to write managed config", { error: e instanceof Error ? e.message : String(e) }) } - process.env[key] = value } - // Track the synced value regardless (for redaction + later cleanup). A - // shadowing shell export is left untouched by the unset pass above, which - // only removes a var whose live value still equals the synced one. - syncedSecretValues.set(key, value) - } - // Write model lockdown config to managed config dir (highest priority config layer) - if (data.config) { + // Persist the synced env to disk so the NEXT CLI invocation can + // load it synchronously at module init (./preload-env.ts) — before + // any provider SDK reads process.env. Without this, the first call + // in a fresh process races: SDKs initialize empty, sync populates + // process.env too late. try { const managedDir = getSyncedConfigDir() await fs.mkdir(managedDir, { recursive: true }) - await atomicWrite( - path.join(managedDir, "openscience-synced.json"), - JSON.stringify({ $schema: "https://syntheticsciences.ai/config.json", ...data.config }, null, 2), - { mode: 0o600 }, - ) - log.info("wrote managed config", { dir: managedDir }) + const envSnapshot: Record = {} + for (const [k, v] of fresh.entries()) { + envSnapshot[k] = v + } + await atomicWrite(path.join(managedDir, "synced-env.json"), JSON.stringify(envSnapshot, null, 2), { + mode: 0o600, + }) } catch (e) { - log.warn("failed to write managed config", { error: e instanceof Error ? e.message : String(e) }) + log.warn("failed to persist synced env", { error: e instanceof Error ? e.message : String(e) }) } - } - // Persist the synced env to disk so the NEXT CLI invocation can - // load it synchronously at module init (./preload-env.ts) — before - // any provider SDK reads process.env. Without this, the first call - // in a fresh process races: SDKs initialize empty, sync populates - // process.env too late. - try { - const managedDir = getSyncedConfigDir() - await fs.mkdir(managedDir, { recursive: true }) - const envSnapshot: Record = {} - for (const [k, v] of fresh.entries()) { - envSnapshot[k] = v - } - await atomicWrite(path.join(managedDir, "synced-env.json"), JSON.stringify(envSnapshot, null, 2), { - mode: 0o600, + log.info("synced services", { + services: Object.entries(data.services) + .filter(([, s]) => s.connected) + .map(([id]) => id), + credentials, }) - } catch (e) { - log.warn("failed to persist synced env", { error: e instanceof Error ? e.message : String(e) }) - } - log.info("synced services", { - services: Object.entries(data.services) - .filter(([, s]) => s.connected) - .map(([id]) => id), - credentials, - }) - - // Log disconnected providers that have a reason so users can diagnose - // BYOK/managed issues without opening the dashboard. - for (const [id, svc] of Object.entries(data.services)) { - if (!svc.connected && svc.reason) { - log.warn(describeReason(id, svc.reason)) + // Log disconnected providers that have a reason so users can diagnose + // BYOK/managed issues without opening the dashboard. + for (const [id, svc] of Object.entries(data.services)) { + if (!svc.connected && svc.reason) { + log.warn(describeReason(id, svc.reason)) + } } - } - // Compatibility only: older releases stored learned skills and the - // third-party install ledger in Atlas. Import those records once after a - // successful login, then keep all skill state local forever. - void import("../skill/migrate") - .then((module) => module.SkillMigration.run()) - .catch((error) => log.warn("legacy skill migration failed", { error: String(error) })) + // Compatibility only: older releases stored learned skills and the + // third-party install ledger in Atlas. Import those records once after a + // successful login, then keep all skill state local forever. + void import("../skill/migrate") + .then((module) => module.SkillMigration.run()) + .catch((error) => log.warn("legacy skill migration failed", { error: String(error) })) - return { user: data.user, credentials } + return { user: data.user, credentials } + }) } catch (e) { log.warn("sync error", { error: e instanceof Error ? e.message : String(e) }) return null @@ -1115,19 +1179,47 @@ export namespace OpenScience { } export function kernelEnv(env: NodeJS.ProcessEnv = process.env) { - return filterEnvForKernel(env) + return { + ...filterEnvForKernel(env), + // A denied ~/.gitconfig is a hard error in Git (unlike a missing file). + // Arbitrary kernels must not read host Git credentials/config, so point + // Git at an inert explicit config instead of widening the read policy. + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } } /** Host credential files that an OS-sandboxed kernel must not read. Atlas * access is intentionally provided by the native host broker instead. */ export function kernelSensitivePaths() { + const home = os.homedir() return [ filepath, path.join(Global.Path.data, "auth.json"), path.join(Global.Path.data, "credentials.json"), + path.join(Global.Path.data, "credentials.key"), + path.join(Global.Path.data, "gcp-service-account.json"), + CredentialLifecycle.revisionPath(), path.join(Global.Path.data, "mcp-auth.json"), + path.join(Global.Path.data, "file-trash"), path.join(getSyncedConfigDir(), "synced-env.json"), - process.env.ATLAS_CLI_CONFIG_PATH || path.join(os.homedir(), ".config", "atlas-cli", "config.json"), + path.join(getSyncedConfigDir(), syncedGcpFilename), + process.env.ATLAS_CLI_CONFIG_PATH || path.join(home, ".config", "atlas-cli", "config.json"), + path.join(home, ".ssh"), + path.join(home, ".aws"), + path.join(home, ".azure"), + path.join(home, ".kaggle"), + path.join(home, ".docker"), + path.join(home, ".config", "gcloud"), + path.join(home, ".config", "gh"), + path.join(home, ".config", "huggingface"), + path.join(home, ".config", "pip", "pip.conf"), + path.join(home, ".config", "rclone", "rclone.conf"), + path.join(home, ".netrc"), + path.join(home, ".git-credentials"), + path.join(home, ".npmrc"), + path.join(home, ".pypirc"), ] } @@ -1234,11 +1326,30 @@ export namespace OpenScience { * use a key the user connected with `openscience login`, without leaking the * shared managed keys. */ export async function subprocessEnv(env: NodeJS.ProcessEnv = process.env): Promise> { + // This is the credential-bearing child-process choke point. It blocks while + // another server is rotating a store and reconciles a committed revision + // before taking the environment snapshot below. + await CredentialLifecycle.ensureFresh() const base = filterEnvForSubprocess(env) const auth = await Auth.all().catch(() => ({}) as Record) // Prepend the bundled atlas CLI to PATH so the agent's native `atlas` // commands resolve without a separate global install. - return withAtlasOnPath(mergeByokEnv(base, auth)) + return { + ...withAtlasOnPath(mergeByokEnv(base, auth)), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } + } + + /** Build and consume a credential-bearing child environment while the + * durable credential mutation lease is held. The callback must spawn and + * durably register its child before returning. */ + export function withSubprocessEnv( + env: NodeJS.ProcessEnv, + action: (snapshot: Record) => T | Promise, + ): Promise { + return CredentialLifecycle.admit(async () => action(await subprocessEnv(env))) } // Default thread/worker caps for scientific Python kernels. Without these, @@ -1336,6 +1447,7 @@ export namespace OpenScience { async function persistToQueue(params: UsageParams, account?: string) { try { + await using operation = await DataRootBarrier.enter(pendingQueuePath) // Serialize against flushPendingUsage so an append can't land between // the flusher's read and its final rewrite (which would delete it). using _ = await Lock.write(pendingQueuePath) @@ -1442,6 +1554,7 @@ export namespace OpenScience { * survives. Best-effort: never throws. */ export async function flushPendingUsage(): Promise { try { + await using operation = await DataRootBarrier.enter(pendingQueuePath) using _ = await Lock.write(pendingQueuePath) const raw = await fs.readFile(pendingQueuePath, "utf-8").catch(() => "") const lines = raw.split("\n").filter(Boolean) @@ -1775,3 +1888,5 @@ export namespace OpenScience { } } } + +CredentialLifecycle.onRefresh(() => OpenScience.reloadSyncedEnv()) diff --git a/backend/cli/src/openscience/preload-env.ts b/backend/cli/src/openscience/preload-env.ts index ab230700..541e845f 100644 --- a/backend/cli/src/openscience/preload-env.ts +++ b/backend/cli/src/openscience/preload-env.ts @@ -18,8 +18,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { isSyncedEnvAllowed } from "./synced-env-policy" -import { loadProjectDotenv } from "./dotenv" +import { scrubAmbientProjectDotenv } from "./dotenv" function syncedEnvPath(): string { const config = process.env.OPENSCIENCE_CONFIG_DIR?.trim() @@ -28,17 +27,18 @@ function syncedEnvPath(): string { return path.join(xdg, "openscience", "synced-env.json") } -// The shipped binary disables Bun's ambient .env auto-load (autoloadDotenv:false) -// so it never ingests a stray .env; load the user's own project .env explicitly -// instead. FIRST, so a shell export still wins over it AND a .env key wins over -// the managed synced value replayed below (BYOK beats the managed wallet). -;(function loadDotenv() { - try { - loadProjectDotenv(process.cwd(), process.env) - } catch { - // never let a malformed .env break boot - } -})() +// The shipped binary disables Bun's ambient .env auto-load +// (`autoloadDotenv:false`). Do not replay a repository .env here: this module +// runs before canonical project identity/trust exists, and even an apparently +// ordinary provider key or GIT_ASKPASS-style variable changes host authority. +// Trusted workloads may load their own dotenv inside the confined process; +// OpenScience credentials belong in the shell or the global Keys settings. +scrubAmbientProjectDotenv(process.cwd(), process.env) + +// Dynamic on purpose: endpoints.ts snapshots the managed base URL at module +// evaluation. It must not evaluate until the ambient repository dotenv has +// been removed above. +const { isSyncedEnvAllowed } = await import("./synced-env-policy") // IIFE so the side effect runs the moment this module is imported. ;(function loadSyncedEnv() { diff --git a/backend/cli/src/patch/index.ts b/backend/cli/src/patch/index.ts index 0efeff54..b6a18c3d 100644 --- a/backend/cli/src/patch/index.ts +++ b/backend/cli/src/patch/index.ts @@ -308,13 +308,23 @@ export namespace Patch { content: string } - export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { - // Read original file content + export function deriveNewContentsFromChunks( + filePath: string, + chunks: UpdateFileChunk[], + approvedContent?: string, + ): ApplyPatchFileUpdate { + // Callers that gate an edit on user approval pass the exact snapshotted + // bytes here. This prevents a second pathname read from silently deriving + // a patch from a different inode during the approval window. let originalContent: string - try { - originalContent = readFileSync(filePath, "utf-8") - } catch (error) { - throw new Error(`Failed to read file ${filePath}: ${error}`) + if (approvedContent !== undefined) { + originalContent = approvedContent + } else { + try { + originalContent = readFileSync(filePath, "utf-8") + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error}`) + } } let originalLines = originalContent.split("\n") diff --git a/backend/cli/src/permission/next.ts b/backend/cli/src/permission/next.ts index 15b6fddd..bf3984c8 100644 --- a/backend/cli/src/permission/next.ts +++ b/backend/cli/src/permission/next.ts @@ -13,6 +13,7 @@ import { SessionFilesystem } from "@/session/filesystem" import { KernelRuntime } from "@/science/kernel/registry" import { Network } from "@/settings/network" import { SessionTraceStore } from "@/session/trace-store" +import { ProjectTrust } from "@/project/trust" export namespace PermissionNext { const log = Log.create({ service: "permission" }) @@ -224,7 +225,15 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input - const rules = spendFilter(request.permission, merge(ruleset, approvals(s, request.sessionID))) + // Configured agent/tool policy is not a user approval. In an untrusted + // clone it may never silently turn an external path request into a grant; + // explicit standing approvals and already-materialized filesystem grants + // remain separate, auditable user decisions. + const configured = + request.permission === "external_directory" && !(await ProjectTrust.allowed(Instance.project)) + ? ruleset.filter((rule) => !(rule.action === "allow" && Wildcard.match(request.permission, rule.permission))) + : ruleset + const rules = spendFilter(request.permission, merge(configured, approvals(s, request.sessionID))) const evaluated = (request.patterns ?? []).map((pattern) => { const rule = evaluate(request.permission, pattern, rules) log.info("evaluated", { permission: request.permission, pattern, action: rule }) diff --git a/backend/cli/src/plugin/index.ts b/backend/cli/src/plugin/index.ts index 6c9b06d5..5b208da9 100644 --- a/backend/cli/src/plugin/index.ts +++ b/backend/cli/src/plugin/index.ts @@ -11,9 +11,16 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@synsci/util/error" import { CopilotAuthPlugin } from "./copilot" +import { ProjectTrust } from "../project/trust" +import { State } from "../project/state" +import { AuthoritySignal } from "../project/authority-signal" export namespace Plugin { const log = Log.create({ service: "plugin" }) + // Provenance must outlive the disposable state entry: a caller can retain a + // hook/tool object while revocation clears the cache. Weak ownership avoids + // retaining the object itself while preserving the per-call trust guard. + const projectHooks = new WeakSet() // Default plugins installed from npm at first run. Keep this list to packages // that actually resolve on the public registry: a package that 404s is retried @@ -50,12 +57,13 @@ export namespace Plugin { } } - const state = Instance.state(async () => { + const compute = async () => { const client = createOpenScienceClient({ baseUrl: "http://openscience.internal", fetch: Server.internalFetch(), }) const config = await Config.getExecution() + const sandbox = await Config.trustedSandbox() const hooks: Hooks[] = [] const input: PluginInput = { client, @@ -84,6 +92,20 @@ export namespace Plugin { .some((name) => plugin.includes(name)) ) continue + const project = await Config.projectControlsPlugin(plugin) + if (project) { + await ProjectTrust.require(Instance.project, "project_plugin") + if (sandbox.enabled === true) { + const message = + `Project plugin ${plugin} was not loaded because project plugins run in the OpenScience host process ` + + "and cannot be isolated by the execution sandbox. Disable the sandbox globally only if you accept that host access." + log.warn("refusing in-process project plugin while sandbox is enabled", { plugin }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ message }).toObject(), + }) + continue + } + } log.info("loading plugin", { path: plugin }) if (!plugin.startsWith("file://")) { const lastAtIndex = plugin.lastIndexOf("@") @@ -109,16 +131,26 @@ export namespace Plugin { }) if (!plugin) continue } - const mod = await import(plugin) - // Prevent duplicate initialization when plugins export the same function - // as both a named export and default export (e.g., `export const X` and `export default X`). - // Object.entries(mod) would return both entries pointing to the same function reference. - const seen = new Set() - for (const [_name, fn] of Object.entries(mod)) { - if (seen.has(fn)) continue - seen.add(fn) - const init = await fn(input) - hooks.push(init) + const load = async () => { + const mod = await import(plugin) + // Prevent duplicate initialization when plugins export the same function + // as both a named export and default export (e.g., `export const X` and `export default X`). + const seen = new Set() + for (const [_name, fn] of Object.entries(mod)) { + if (seen.has(fn)) continue + seen.add(fn) + const init = await fn(input) + hooks.push(init) + if (project) projectHooks.add(init) + } + } + if (project) { + await AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "project_plugin") + await load() + }) + } else { + await load() } } @@ -126,7 +158,18 @@ export namespace Plugin { hooks, input, } - }) + } + + const state = Instance.state(compute) + + /** Remove project plugin hooks from every subsequent trigger/tool lookup. */ + export function invalidate() { + State.clear(Instance.directory, compute) + } + + export function projectOwned(hook: Hooks) { + return projectHooks.has(hook) + } export async function trigger< Name extends Exclude, "auth" | "event" | "tool">, @@ -134,7 +177,9 @@ export namespace Plugin { Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { if (!name) return output - for (const hook of await state().then((x) => x.hooks)) { + const current = await state() + for (const hook of current.hooks) { + if (projectHooks.has(hook)) await ProjectTrust.require(Instance.project, "project_plugin") const fn = hook[name] if (!fn) continue // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you @@ -157,8 +202,9 @@ export namespace Plugin { await hook.config?.(config) } Bus.subscribeAll(async (input) => { - const hooks = await state().then((x) => x.hooks) - for (const hook of hooks) { + const current = await state() + for (const hook of current.hooks) { + if (projectHooks.has(hook) && !(await ProjectTrust.allowed(Instance.project))) continue hook["event"]?.({ event: input, }) diff --git a/backend/cli/src/process/darwin-responsibility-launcher.ts b/backend/cli/src/process/darwin-responsibility-launcher.ts new file mode 100644 index 00000000..c1453247 --- /dev/null +++ b/backend/cli/src/process/darwin-responsibility-launcher.ts @@ -0,0 +1,224 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" +import { DarwinResponsibility } from "./darwin-responsibility" + +export const DARWIN_RESPONSIBILITY_LAUNCHER_ARG = "__openscience_darwin_responsibility_launcher__" +const SUPERVISE = "supervise" +export const DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX = ".owned" + +/** + * A two-stage Darwin launcher. + * + * Stage one waits until the durable ledger entry exists. It then uses + * POSIX_SPAWN_SETEXEC + responsibility_spawnattrs_setdisclaim to replace + * itself at the same PID with stage two as an independent kernel + * responsibility root. Stage two remains alive until every responsibility + * member exits, so setsid()+double-fork cannot escape by reparenting to + * launchd. It also observes the exact start identity of the owning server and + * reaps the tree if that server is killed. + */ +export namespace DarwinResponsibilityLauncher { + export interface Invocation { + file: string + args: string[] + release?: string + } + + function sourceArgs(): string[] { + const executable = path.basename(process.execPath).toLowerCase() + const sourceRuntime = executable === "bun" || executable === "bun.exe" + const entry = fileURLToPath(new URL("../index.ts", import.meta.url)) + return sourceRuntime ? [entry] : [] + } + + export function wrap(input: { + file: string + args?: string[] + shell?: boolean | string + /** Marker written after optional session creation but before ledger release. */ + ready?: string + /** Create an owned POSIX session when the spawning API has no detached flag. */ + ownSession?: boolean + }): Invocation { + if (process.platform !== "darwin") return { file: input.file, args: input.args ?? [] } + const ownerIdentity = DarwinResponsibility.identity(process.pid) + if (!ownerIdentity) throw new Error(`Could not capture macOS owner identity for process ${process.pid}`) + const release = path.join(os.tmpdir(), `openscience-responsibility-release-${process.pid}-${crypto.randomUUID()}`) + return { + file: process.execPath, + args: [ + ...sourceArgs(), + DARWIN_RESPONSIBILITY_LAUNCHER_ARG, + release, + input.ready ?? "-", + input.ownSession ? "1" : "0", + input.shell === true ? "1" : typeof input.shell === "string" ? input.shell : "0", + String(process.pid), + ownerIdentity, + input.file, + ...(input.args ?? []), + ], + release, + } + } + + async function waitForRelease(file: string): Promise { + for (let attempt = 0; attempt < 3_000; attempt++) { + const owner = await fs.readFile(file, "utf8").catch(() => undefined) + if (owner?.trim() === String(process.pid)) return + if (attempt === 2_999) throw new Error("Timed out waiting for durable macOS responsibility ownership") + await Bun.sleep(10) + } + } + + async function reapOwned(): Promise { + const owner = DarwinResponsibility.unique(process.pid) + if (!owner) throw new Error(`Could not resolve macOS responsibility identity for ${process.pid}`) + for (let attempt = 0; attempt < 250; attempt++) { + const members = DarwinResponsibility.uniqueMembers(owner).filter((pid) => pid !== process.pid) + if (!members.length) return + for (const pid of members) { + if (!DarwinResponsibility.uniquelyOwns(owner, pid)) continue + try { + process.kill(pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error + } + } + await Bun.sleep(20) + } + throw new Error(`macOS responsibility root ${process.pid} could not reap every owned process`) + } + + async function supervise(args: string[]): Promise { + const [activation, shell, ownerText, ownerIdentity, file, ...commandArgs] = args + const owner = Number(ownerText) + if (!activation || !shell || !Number.isSafeInteger(owner) || owner <= 0 || !ownerIdentity || !file) { + throw new Error("The macOS responsibility supervisor received an invalid launch contract") + } + if (DarwinResponsibility.responsible(process.pid) !== process.pid || !DarwinResponsibility.unique(process.pid)) { + throw new Error(`Process ${process.pid} did not become an independent macOS responsibility root`) + } + // The source launcher enters through index.ts, whose static module graph + // installs the server's normal SIGINT/SIGTERM exit hooks. Those hooks are + // correct for a server, but would make this internal supervisor exit with + // 130 before it can forward an interrupt to a persistent kernel. Replace + // them with the supervisor-specific forwarding contract below. + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) process.removeAllListeners(signal) + // Do not expose project code until the durable ledger has persisted the + // kernel responsibility unique ID. If registration fails, the supervisor + // is still an empty process-group root that can be safely torn down. + try { + await waitForRelease(activation) + } finally { + await fs.rm(activation, { force: true }).catch(() => undefined) + } + + let child: ReturnType + try { + child = spawn(file, commandArgs, { + cwd: process.cwd(), + env: process.env, + shell: shell === "1" ? true : shell === "0" ? false : shell, + stdio: "inherit", + // Keep the responsibility supervisor out of the payload's process + // group. Callers signal the registered supervisor group; if the + // payload shared it, it would receive that signal once from the + // kernel and a second time from the forwarding handler below. + // Responsibility ownership is independent of POSIX process groups, + // so a new payload session preserves exact descendant containment. + detached: true, + }) + } catch (error) { + await reapOwned() + throw error + } + const result = new Promise((resolve, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1))) + }) + const forward = (signal: NodeJS.Signals) => { + try { + // Forward exactly once to the payload leader. Responsibility teardown + // remains the descendant-wide hard-stop path; broad group delivery + // here can make runtime wrappers and their interpreter both translate + // the same interrupt. + child.kill(signal) + } catch {} + } + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) process.on(signal, () => forward(signal)) + + let settled = false + let code = 1 + let failure: unknown + void result.then( + (value) => { + settled = true + code = value + }, + (error) => { + settled = true + failure = error + }, + ) + while (true) { + if (DarwinResponsibility.identity(owner) !== ownerIdentity) { + await reapOwned() + return 137 + } + if (settled) { + // A normal command completion is also a lifecycle boundary. Reap any + // background or fully reparented members before reporting the command + // complete, matching the durable ledger's completion contract. + await reapOwned() + if (failure) throw failure + return code + } + await Bun.sleep(20) + } + } + + export async function run(args: string[]): Promise { + if (process.platform !== "darwin") throw new Error("The macOS responsibility launcher requires Darwin") + if (args[0] === SUPERVISE) return supervise(args.slice(1)) + + const [release, ready, ownSession, shell, owner, ownerIdentity, file, ...commandArgs] = args + if (!release || !ready || !ownSession || !shell || !owner || !ownerIdentity || !file) { + throw new Error("The macOS responsibility launcher requires a release marker and command") + } + if (ownSession === "1") { + const session = DarwinResponsibility.startSession() + if (session !== process.pid) { + throw new Error(`Could not establish an owned macOS process group (setsid returned ${session})`) + } + } else if (ownSession !== "0") { + throw new Error("The macOS responsibility launcher received an invalid session contract") + } + if (ready !== "-") { + await fs.writeFile(ready, String(process.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + } + try { + await waitForRelease(release) + } finally { + await fs.rm(release, { force: true }).catch(() => undefined) + } + DarwinResponsibility.execSelfResponsible({ + file: process.execPath, + args: [ + ...sourceArgs(), + DARWIN_RESPONSIBILITY_LAUNCHER_ARG, + SUPERVISE, + `${release}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, + shell, + owner, + ownerIdentity, + file, + ...commandArgs, + ], + env: process.env, + }) + } +} diff --git a/backend/cli/src/process/darwin-responsibility.ts b/backend/cli/src/process/darwin-responsibility.ts new file mode 100644 index 00000000..2f6bd57a --- /dev/null +++ b/backend/cli/src/process/darwin-responsibility.ts @@ -0,0 +1,246 @@ +import { dlopen, FFIType, ptr } from "bun:ffi" + +/** + * macOS keeps a kernel responsibility chain independently of POSIX parentage. + * `responsibility_get_pid_responsible_for_pid` therefore continues to point a + * setsid()+double-fork descendant at the long-lived process that launched the + * tree after launchd has become its PPID. That gives revocation code the + * missing ownership predicate without trusting process names or mutable argv. + * + * The symbol is part of libSystem's shipped ABI but is not declared by the + * public SDK headers. Availability is probed at runtime and all operations + * fail closed; callers must retain their process-group/ancestry path as a + * compatibility fallback on older macOS releases. + */ +export namespace DarwinResponsibility { + const PROCESS_ALL_PIDS = 1 + const PROC_PIDTBSDINFO = 3 + const BSD_INFO_SIZE = 136 + const POSIX_SPAWN_SETEXEC = 0x0040 + + const definitions = { + proc_listpids: { + args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + responsibility_get_pid_responsible_for_pid: { + args: [FFIType.i32], + returns: FFIType.i32, + }, + responsibility_get_uniqueid_responsible_for_pid: { + args: [FFIType.i32], + returns: FFIType.u64, + }, + posix_spawnattr_init: { + args: [FFIType.ptr], + returns: FFIType.i32, + }, + posix_spawnattr_setflags: { + args: [FFIType.ptr, FFIType.i16], + returns: FFIType.i32, + }, + responsibility_spawnattrs_setdisclaim: { + args: [FFIType.ptr, FFIType.bool], + returns: FFIType.i32, + }, + posix_spawnattr_destroy: { + args: [FFIType.ptr], + returns: FFIType.i32, + }, + posix_spawn: { + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + setsid: { + args: [], + returns: FFIType.i32, + }, + } as const + + type Symbols = { + proc_listpids(type: number, typeinfo: number, buffer: Buffer | null, size: number): number + proc_pidinfo(pid: number, flavor: number, arg: bigint, buffer: Buffer, size: number): number + responsibility_get_pid_responsible_for_pid(pid: number): number + responsibility_get_uniqueid_responsible_for_pid(pid: number): bigint + posix_spawnattr_init(attributes: Buffer): number + posix_spawnattr_setflags(attributes: Buffer, flags: number): number + responsibility_spawnattrs_setdisclaim(attributes: Buffer, disclaim: boolean): number + posix_spawnattr_destroy(attributes: Buffer): number + posix_spawn(pid: Buffer, file: Buffer, actions: null, attributes: Buffer, argv: Buffer, environment: Buffer): number + setsid(): number + } + + let library: ReturnType | undefined + let unavailable = false + + function symbols(): Symbols | undefined { + if (process.platform !== "darwin" || unavailable) return + try { + library ??= dlopen("/usr/lib/libSystem.B.dylib", definitions) + return library.symbols as unknown as Symbols + } catch { + unavailable = true + } + } + + function list(symbol: Symbols): number[] { + // The process table can grow between the size query and copy. Add slack + // and retry rather than silently treating a truncated snapshot as proof + // that an owned daemon has gone away. + for (let attempt = 0; attempt < 4; attempt++) { + const needed = symbol.proc_listpids(PROCESS_ALL_PIDS, 0, null, 0) + if (needed <= 0) return [] + const buffer = Buffer.alloc(needed + Math.max(16_384, needed >> 1)) + const copied = symbol.proc_listpids(PROCESS_ALL_PIDS, 0, buffer, buffer.length) + if (copied <= 0) return [] + if (copied < buffer.length) { + const pids: number[] = [] + for (let offset = 0; offset + 4 <= copied; offset += 4) { + const pid = buffer.readInt32LE(offset) + if (pid > 0) pids.push(pid) + } + return pids + } + } + throw new Error("macOS process table changed continuously while enumerating responsibility ownership") + } + + function info(symbol: Symbols, pid: number): Buffer | undefined { + const info = Buffer.alloc(BSD_INFO_SIZE) + const size = symbol.proc_pidinfo(pid, PROC_PIDTBSDINFO, 0n, info, info.length) + return size === info.length && info.readUInt32LE(12) === pid ? info : undefined + } + + function exists(symbol: Symbols, pid: number): boolean { + return !!info(symbol, pid) + } + + export function available(): boolean { + return !!symbols() + } + + /** Exact kernel process-start token used by the trusted supervisor to notice + * that its owning OpenScience server exited, without a PID-reuse race. */ + export function identity(pid: number): string | undefined { + if (!Number.isSafeInteger(pid) || pid <= 0) return + const symbol = symbols() + const value = symbol && info(symbol, pid) + return value ? `${value.readBigUInt64LE(120)}:${value.readBigUInt64LE(128)}` : undefined + } + + /** Return the kernel-designated responsible PID, or undefined when the + * process vanished or this macOS ABI is unavailable. */ + export function responsible(pid: number): number | undefined { + if (!Number.isSafeInteger(pid) || pid <= 0) return + const symbol = symbols() + if (!symbol || !exists(symbol, pid)) return + const value = symbol.responsibility_get_pid_responsible_for_pid(pid) + return Number.isSafeInteger(value) && value > 0 ? value : undefined + } + + /** Snapshot every live process whose kernel responsibility root is owner. + * The owner is included only while it still exists. Callers authenticate + * returned PIDs with their normal process-start identity before signalling. */ + export function members(owner: number): number[] { + if (!Number.isSafeInteger(owner) || owner <= 0) return [] + const symbol = symbols() + if (!symbol || !exists(symbol, owner)) return [] + return list(symbol).filter( + (pid) => exists(symbol, pid) && symbol.responsibility_get_pid_responsible_for_pid(pid) === owner, + ) + } + + /** Recheck ownership immediately before a PID-targeted operation. */ + export function owns(owner: number, pid: number): boolean { + return responsible(pid) === owner + } + + /** Kernel responsibility identity independent of the root process's current + * PID incarnation. Persist this decimal string in durable ledgers. */ + export function unique(pid: number): string | undefined { + if (!Number.isSafeInteger(pid) || pid <= 0) return + const symbol = symbols() + if (!symbol || !exists(symbol, pid)) return + const value = symbol.responsibility_get_uniqueid_responsible_for_pid(pid) + return value > 0n ? value.toString() : undefined + } + + /** Snapshot all live processes with an exact responsibility unique ID. */ + export function uniqueMembers(owner: string): number[] { + if (!/^[1-9][0-9]{0,19}$/.test(owner)) return [] + const symbol = symbols() + if (!symbol) return [] + const expected = BigInt(owner) + return list(symbol).filter( + (pid) => exists(symbol, pid) && symbol.responsibility_get_uniqueid_responsible_for_pid(pid) === expected, + ) + } + + export function uniquelyOwns(owner: string, pid: number): boolean { + return unique(pid) === owner + } + + /** Establish a new POSIX session before a transport publishes its PID. This + * is used only for APIs that cannot request `detached` at spawn time (the + * MCP SDK's stdio transport). */ + export function startSession(): number { + if (process.platform !== "darwin") throw new Error("macOS session creation is only available on Darwin") + const symbol = symbols() + if (!symbol) throw new Error("macOS responsibility spawn APIs are unavailable") + return symbol.setsid() + } + + function cstring(value: string): Buffer { + if (value.includes("\0")) throw new Error("macOS responsibility launcher arguments cannot contain NUL bytes") + return Buffer.from(`${value}\0`) + } + + function pointers(values: Buffer[]): Buffer { + const table = Buffer.alloc((values.length + 1) * 8) + values.forEach((value, index) => table.writeBigUInt64LE(BigInt(ptr(value)), index * 8)) + return table + } + + /** Atomically replace the current process while making its unchanged PID a + * fresh kernel responsibility root. POSIX_SPAWN_SETEXEC preserves the + * launcher's stdio, cwd, process group, and process-start identity. Success + * never returns. */ + export function execSelfResponsible(input: { file: string; args: string[]; env?: NodeJS.ProcessEnv }): never { + if (process.platform !== "darwin") throw new Error("macOS responsibility execution is only available on Darwin") + if (!pathAbsolute(input.file)) + throw new Error(`macOS responsibility execution requires an absolute file: ${input.file}`) + const symbol = symbols() + if (!symbol) throw new Error("macOS responsibility spawn APIs are unavailable") + + const file = cstring(input.file) + const argvValues = [file, ...input.args.map(cstring)] + const environmentValues = Object.entries(input.env ?? process.env).flatMap(([key, value]) => + value === undefined ? [] : [cstring(`${key}=${value}`)], + ) + const argv = pointers(argvValues) + const environment = pointers(environmentValues) + const attributes = Buffer.alloc(8) + const pid = Buffer.alloc(4) + const check = (action: string, code: number) => { + if (code !== 0) throw new Error(`${action} failed (errno ${code})`) + } + + check("posix_spawnattr_init", symbol.posix_spawnattr_init(attributes)) + try { + check("responsibility_spawnattrs_setdisclaim", symbol.responsibility_spawnattrs_setdisclaim(attributes, true)) + check("posix_spawnattr_setflags", symbol.posix_spawnattr_setflags(attributes, POSIX_SPAWN_SETEXEC)) + check("posix_spawn(POSIX_SPAWN_SETEXEC)", symbol.posix_spawn(pid, file, null, attributes, argv, environment)) + } finally { + symbol.posix_spawnattr_destroy(attributes) + } + throw new Error("posix_spawn(POSIX_SPAWN_SETEXEC) returned after successful process replacement") + } + + function pathAbsolute(value: string): boolean { + return value.startsWith("/") + } +} diff --git a/backend/cli/src/process/process-identity.ts b/backend/cli/src/process/process-identity.ts new file mode 100644 index 00000000..269c37b9 --- /dev/null +++ b/backend/cli/src/process/process-identity.ts @@ -0,0 +1,57 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import { WindowsJob } from "./windows-job" + +/** Exact, PID-reuse-safe process-start identities shared by durable owners. */ +export namespace ProcessIdentity { + async function linux(pid: number): Promise { + const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ESRCH") return undefined + throw error + }) + if (!stat) return + const close = stat.lastIndexOf(")") + if (close < 0) return + const started = stat + .slice(close + 2) + .trim() + .split(/\s+/)[19] + return started ? `linux:${started}` : undefined + } + + async function darwin(pid: number): Promise { + const { dlopen, FFIType, ptr } = await import("bun:ffi") + const lib = dlopen("/usr/lib/libproc.dylib", { + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + }) + try { + // PROC_PIDTBSDINFO. The final two uint64 fields are the process start + // time with microsecond precision, so a recycled PID never authenticates + // an abandoned data-root operation marker. + const info = Buffer.alloc(136) + const size = lib.symbols.proc_pidinfo(pid, 3, 0n, ptr(info), info.length) + if (size !== info.length || info.readUInt32LE(12) !== pid) return + return `darwin:${info.readBigUInt64LE(120)}:${info.readBigUInt64LE(128)}` + } finally { + lib.close() + } + } + + /** Stable, hashed OS process-start identity. */ + export async function capture(pid: number): Promise { + const raw = await (async () => { + if (process.platform === "linux") return linux(pid) + if (process.platform === "darwin") return darwin(pid) + if (process.platform === "win32") return WindowsJob.identity(pid) + })() + return raw ? crypto.createHash("sha256").update(raw).digest("hex") : undefined + } + + export async function owns(pid: number, expected: string | undefined): Promise { + if (!expected) return false + return (await capture(pid)) === expected + } +} diff --git a/backend/cli/src/process/windows-job-launcher.ts b/backend/cli/src/process/windows-job-launcher.ts new file mode 100644 index 00000000..cbc44d7d --- /dev/null +++ b/backend/cli/src/process/windows-job-launcher.ts @@ -0,0 +1,161 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" +import { DarwinResponsibilityLauncher } from "./darwin-responsibility-launcher" +import { ProcessIdentity } from "./process-identity" + +export const WINDOWS_JOB_LAUNCHER_ARG = "__openscience_windows_job_launcher__" + +export namespace WindowsJobLauncher { + export interface Invocation { + file: string + args: string[] + release?: string + } + + export function wrap(input: { + file: string + args?: string[] + shell?: boolean | string + linuxOwner?: { pid: number; identity: string } + }): Invocation { + if (process.platform === "darwin") return DarwinResponsibilityLauncher.wrap(input) + if (process.platform !== "win32" && !(process.platform === "linux" && input.linuxOwner)) { + return { file: input.file, args: input.args ?? [] } + } + const release = path.join(os.tmpdir(), `openscience-job-release-${process.pid}-${crypto.randomUUID()}`) + const executable = path.basename(process.execPath).toLowerCase() + const sourceRuntime = executable === "bun" || executable === "bun.exe" + const entry = fileURLToPath(new URL("../index.ts", import.meta.url)) + return { + file: process.execPath, + args: [ + ...(sourceRuntime ? [entry] : []), + WINDOWS_JOB_LAUNCHER_ARG, + release, + ...(input.linuxOwner ? ["linux", String(input.linuxOwner.pid), input.linuxOwner.identity] : []), + input.shell === true ? "1" : typeof input.shell === "string" ? input.shell : "0", + input.file, + ...(input.args ?? []), + ], + release, + } + } + + async function supervise( + file: string, + commandArgs: string[], + shell: string, + owner?: { pid: number; identity: string }, + ): Promise { + // Internal launchers enter through index.ts, whose static graph installs + // the server's signal handlers. Replace those with this supervisor's + // forwarding contract so a signal is not translated twice. + for (const signal of ["SIGINT", "SIGTERM"] as const) process.removeAllListeners(signal) + const child = spawn(file, commandArgs, { + cwd: process.cwd(), + env: process.env, + shell: shell === "1" ? true : shell === "0" ? false : shell, + windowsHide: true, + stdio: "inherit", + }) + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => child.kill(signal)) + } + const result = new Promise((resolve, reject) => { + child.once("error", reject) + child.once("exit", (code) => resolve(code ?? 1)) + }) + if (!owner) return result + + let settled = false + let code = 1 + let failure: unknown + void result.then( + (value) => { + settled = true + code = value + }, + (error) => { + settled = true + failure = error + }, + ) + while (!settled) { + if (!(await ProcessIdentity.owns(owner.pid, owner.identity))) { + // The durable server owner disappeared. Kill the direct sandbox root; + // bubblewrap's die-with-parent/PID namespace reaps every descendant + // when this supervisor returns and exits immediately afterward. + try { + child.kill("SIGKILL") + } catch {} + return 137 + } + await Bun.sleep(20) + } + if (failure) throw failure + return code + } + + async function runLinux(args: string[]): Promise { + const [release, , ownerText, ownerIdentity, shell, file, ...commandArgs] = args + const owner = Number(ownerText) + if (!release || !Number.isSafeInteger(owner) || owner <= 0 || !ownerIdentity || !shell || !file) { + throw new Error("The Linux durable-launch gate requires an owner identity and command") + } + try { + for (let attempt = 0; attempt < 3_000; attempt++) { + const assigned = await fs.readFile(release, "utf8").catch(() => undefined) + if (assigned?.trim() === String(process.pid)) { + return supervise(file, commandArgs, shell, { pid: owner, identity: ownerIdentity }) + } + // Before release, the launcher has inherited the prospective job's + // old-root handles but cannot execute project code. If the server dies + // in this registration window, exit silently so relocation can drain. + if (!(await ProcessIdentity.owns(owner, ownerIdentity))) return 137 + await Bun.sleep(10) + } + return 124 + } finally { + await fs.rm(release, { force: true }).catch(() => undefined) + } + } + + export async function run(args: string[]): Promise { + if (args[1] === "linux") return runLinux(args) + const [release, shell, file, ...commandArgs] = args + if (!release || !shell || !file) + throw new Error("The Windows Job Object launcher requires a release marker and command") + try { + for (let attempt = 0; attempt < 3_000; attempt++) { + const owner = await fs.readFile(release, "utf8").catch(() => undefined) + if (owner?.trim() === String(process.pid)) break + if (attempt === 2_999) throw new Error("Timed out waiting for durable Windows Job Object ownership") + await Bun.sleep(10) + } + const child = spawn(file, commandArgs, { + cwd: process.cwd(), + env: process.env, + shell: shell === "1" ? true : shell === "0" ? false : shell, + windowsHide: true, + stdio: "inherit", + }) + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => child.kill(signal)) + } + return new Promise((resolve, reject) => { + child.once("error", reject) + child.once("exit", (code) => resolve(code ?? 1)) + }) + } finally { + await fs.rm(release, { force: true }).catch(() => undefined) + } + } + + export async function release(file: string, pid: number): Promise { + await fs.writeFile(file, String(pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + } +} diff --git a/backend/cli/src/process/windows-job.ts b/backend/cli/src/process/windows-job.ts new file mode 100644 index 00000000..be498d8b --- /dev/null +++ b/backend/cli/src/process/windows-job.ts @@ -0,0 +1,279 @@ +import crypto from "node:crypto" +import fs from "node:fs" +import { dlopen, FFIType } from "bun:ffi" + +/** + * Windows process-tree ownership backed by named Job Objects. + * + * A handle is intentionally kept open by the process that registers the + * child. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE then makes an ungraceful owner + * exit an OS-enforced teardown boundary. The random, persisted name lets a + * different OpenScience process open and terminate the same job while the + * original owner is still alive. + */ +export namespace WindowsJob { + type Handle = number | bigint + + export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + export const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 + export const EXTENDED_LIMIT_SIZE_X64 = 144 + export const LIMIT_FLAGS_OFFSET_X64 = 16 + + const JOB_OBJECT_TERMINATE = 0x0008 + const JOB_OBJECT_QUERY = 0x0004 + const SYNCHRONIZE = 0x00100000 + const PROCESS_TERMINATE = 0x0001 + const PROCESS_SET_QUOTA = 0x0100 + const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + const WAIT_OBJECT_0 = 0 + const WAIT_FAILED = 0xffffffff + const WAIT_TIMEOUT = 0x00000102 + const ERROR_FILE_NOT_FOUND = 2 + const ERROR_ALREADY_EXISTS = 183 + const jobs = new Map() + + const definitions = { + CreateJobObjectW: { + args: [FFIType.ptr, FFIType.ptr], + returns: FFIType.u64, + }, + OpenJobObjectW: { + args: [FFIType.u32, FFIType.i32, FFIType.ptr], + returns: FFIType.u64, + }, + SetInformationJobObject: { + args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }, + AssignProcessToJobObject: { + args: [FFIType.u64, FFIType.u64], + returns: FFIType.i32, + }, + TerminateJobObject: { + args: [FFIType.u64, FFIType.u32], + returns: FFIType.i32, + }, + IsProcessInJob: { + args: [FFIType.u64, FFIType.u64, FFIType.ptr], + returns: FFIType.i32, + }, + OpenProcess: { + args: [FFIType.u32, FFIType.i32, FFIType.u32], + returns: FFIType.u64, + }, + GetProcessTimes: { + args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + WaitForSingleObject: { + args: [FFIType.u64, FFIType.u32], + returns: FFIType.u32, + }, + CloseHandle: { + args: [FFIType.u64], + returns: FFIType.i32, + }, + GetLastError: { + args: [], + returns: FFIType.u32, + }, + } as const + + const openKernel = () => dlopen("kernel32.dll", definitions) + let kernel: ReturnType | undefined + + function api() { + if (process.platform !== "win32") throw new Error("Windows Job Objects are only available on Windows") + if (process.arch !== "x64" && process.arch !== "arm64") { + throw new Error(`Windows Job Objects require a 64-bit Windows runtime, received ${process.arch}`) + } + kernel ??= openKernel() + return kernel.symbols + } + + function wide(value: string): Buffer { + return Buffer.from(`${value}\0`, "utf16le") + } + + function empty(handle: Handle): boolean { + return handle === 0 || handle === 0n + } + + function code(): number { + return Number(api().GetLastError()) + } + + function failure(action: string, error = code()): Error { + return new Error(`${action} failed (Win32 error ${error})`) + } + + function close(handle: Handle): void { + if (empty(handle)) return + api().CloseHandle(handle) + } + + function limits(): Buffer { + const info = Buffer.alloc(EXTENDED_LIMIT_SIZE_X64) + info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET_X64) + return info + } + + function create(name: string): Handle { + const symbols = api() + const handle = symbols.CreateJobObjectW(null, wide(name)) as Handle + if (empty(handle)) throw failure(`CreateJobObjectW(${name})`) + const created = code() + if (created === ERROR_ALREADY_EXISTS) { + close(handle) + throw failure(`CreateJobObjectW(${name})`, created) + } + const info = limits() + if (!symbols.SetInformationJobObject(handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, info, info.length)) { + const error = failure(`SetInformationJobObject(${name})`) + close(handle) + throw error + } + return handle + } + + function open(name: string, access = JOB_OBJECT_TERMINATE | JOB_OBJECT_QUERY | SYNCHRONIZE): Handle | undefined { + const handle = api().OpenJobObjectW(access, 0, wide(name)) as Handle + if (!empty(handle)) return handle + const error = code() + if (error === ERROR_FILE_NOT_FOUND) return + throw failure(`OpenJobObjectW(${name})`, error) + } + + function processHandle(pid: number, access: number): Handle | undefined { + const handle = api().OpenProcess(access, 0, pid) as Handle + if (!empty(handle)) return handle + } + + export function valid(name: string | undefined): name is string { + return !!name && /^Local\\OpenScience-[a-f0-9]{64}$/.test(name) + } + + export function name(id: string, nonce: string = crypto.randomUUID()): string { + const digest = crypto.createHash("sha256").update(`${id}\0${nonce}`).digest("hex") + return `Local\\OpenScience-${digest}` + } + + function identityForHandle(handle: Handle): string | undefined { + const creation = Buffer.alloc(8) + const exit = Buffer.alloc(8) + const kernelTime = Buffer.alloc(8) + const userTime = Buffer.alloc(8) + if (!api().GetProcessTimes(handle, creation, exit, kernelTime, userTime)) return + const ticks = (BigInt(creation.readUInt32LE(4)) << 32n) | BigInt(creation.readUInt32LE(0)) + return `win32:${ticks}` + } + + function hashedIdentity(handle: Handle): string | undefined { + const raw = identityForHandle(handle) + return raw ? crypto.createHash("sha256").update(raw).digest("hex") : undefined + } + + /** + * Atomically establishes the OS ownership boundary for a live child. + * + * The expected identity is checked through the same process handle that is + * assigned to the Job. This closes the PID-reuse window between a ledger's + * initial identity capture and acquiring its cross-process write lease. + */ + export function assign(input: { id: string; pid: number; expectedIdentity?: string }): string { + const job = name(input.id) + const handle = create(job) + const child = processHandle( + input.pid, + PROCESS_TERMINATE | PROCESS_SET_QUOTA | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, + ) + if (!child) { + const error = failure(`OpenProcess(${input.pid})`) + close(handle) + throw error + } + try { + if (input.expectedIdentity && hashedIdentity(child) !== input.expectedIdentity) { + throw new Error(`Process ${input.pid} changed identity before Windows Job Object assignment`) + } + if (!api().AssignProcessToJobObject(handle, child)) { + throw failure(`AssignProcessToJobObject(${input.pid})`) + } + const member = Buffer.alloc(4) + if (!api().IsProcessInJob(child, handle, member)) { + throw failure(`IsProcessInJob(${input.pid})`) + } + if (!member.readUInt32LE()) throw new Error(`Process ${input.pid} was not retained by Windows Job Object ${job}`) + jobs.set(job, handle) + return job + } catch (error) { + close(handle) + throw error + } finally { + close(child) + } + } + + /** Stable process-start identity from the kernel's creation FILETIME. */ + export function identity(pid: number): string | undefined { + if (process.platform !== "win32") return + const handle = processHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION) + if (!handle) return + try { + return identityForHandle(handle) + } finally { + close(handle) + } + } + + export function contains(name: string, pid: number): boolean { + const job = jobs.get(name) ?? open(name) + if (!job) return false + const owned = jobs.has(name) + const child = processHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION) + if (!child) { + if (!owned) close(job) + return false + } + const member = Buffer.alloc(4) + try { + if (!api().IsProcessInJob(child, job, member)) { + throw failure(`IsProcessInJob(${pid})`) + } + return member.readUInt32LE() !== 0 + } finally { + close(child) + if (!owned) close(job) + } + } + + /** Terminates the named job and verifies that its full process tree exits. */ + export function terminate(name: string): boolean { + const held = jobs.get(name) + const job = held ?? open(name) + if (!job) return false + try { + if (!api().TerminateJobObject(job, 1)) throw failure(`TerminateJobObject(${name})`) + const result = Number(api().WaitForSingleObject(job, 5_000)) + if (result === WAIT_OBJECT_0) return true + if (result === WAIT_TIMEOUT) throw new Error(`Windows Job Object ${name} did not terminate within 5000ms`) + if (result === WAIT_FAILED) throw failure(`WaitForSingleObject(${name})`) + throw new Error(`WaitForSingleObject(${name}) returned ${result}`) + } finally { + if (held) jobs.delete(name) + close(job) + } + } + + export function heldForTests(name: string): boolean { + return jobs.has(name) + } + + export function limitsForTests(): Buffer { + return limits() + } + + export function release(file: string, pid: number): void { + fs.writeFileSync(file, String(pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + } +} diff --git a/backend/cli/src/project/authority-process.ts b/backend/cli/src/project/authority-process.ts new file mode 100644 index 00000000..9b3a713d --- /dev/null +++ b/backend/cli/src/project/authority-process.ts @@ -0,0 +1,539 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "@/global" +import { DataRootBarrier } from "@/global/data-root-barrier" +import { DarwinResponsibility } from "@/process/darwin-responsibility" +import { DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX } from "@/process/darwin-responsibility-launcher" +import { WindowsJob } from "@/process/windows-job" +import { FileLease } from "@/util/file-lease" + +/** + * Durable ownership for project-authorized processes that otherwise exist only + * in a server's memory. Trust/filesystem revocation uses this record after an + * owning server is SIGKILLed. Every signal is guarded by an OS process-start + * identity and, on POSIX, exact identities for every observed process-group + * member plus the leader's live descendant closure. Linux sandboxes add a PID + * namespace; macOS launches each durable runtime as an independent kernel + * responsibility root, so fully reparented double-fork descendants remain + * owned after they leave both ancestry and the POSIX process group. + */ +export namespace AuthorityProcessLedger { + export type Kind = "pty" | "biology" | "kernel" + + interface Entry { + version: 1 + id: string + kind: Kind + pid: number + identity: string + owns_process_group: boolean + darwin_responsibility_uniqueid?: string + windows_job?: string + owner_pid: number + project_id: string + session_id: string + authority_generation: string + created_at: string + } + + export interface Scope { + id?: string + kind?: Kind + projectID?: string + sessionID?: string + authorityGeneration?: string + } + + const filepath = path.join(Global.Path.data, "authority-processes.json") + const lockpath = `${filepath}.lock` + + function valid(value: unknown): value is Entry { + if (!value || typeof value !== "object") return false + const item = value as Partial + return ( + item.version === 1 && + typeof item.id === "string" && + !!item.id && + (item.kind === "pty" || item.kind === "biology" || item.kind === "kernel") && + typeof item.pid === "number" && + Number.isSafeInteger(item.pid) && + item.pid > 0 && + typeof item.identity === "string" && + /^[a-f0-9]{64}$/.test(item.identity) && + typeof item.owns_process_group === "boolean" && + (item.darwin_responsibility_uniqueid === undefined || + (typeof item.darwin_responsibility_uniqueid === "string" && + /^[1-9][0-9]{0,19}$/.test(item.darwin_responsibility_uniqueid))) && + (item.windows_job === undefined || WindowsJob.valid(item.windows_job)) && + typeof item.owner_pid === "number" && + Number.isSafeInteger(item.owner_pid) && + item.owner_pid > 0 && + typeof item.project_id === "string" && + !!item.project_id && + typeof item.session_id === "string" && + !!item.session_id && + typeof item.authority_generation === "string" && + !!item.authority_generation && + typeof item.created_at === "string" + ) + } + + async function read(): Promise { + const text = await fs.readFile(filepath, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + if (text === undefined) return [] + const parsed: unknown = JSON.parse(text) + if (!Array.isArray(parsed) || !parsed.every(valid)) { + throw new Error(`Authority process ledger ${filepath} is corrupt; refusing unsafe process revocation`) + } + return parsed + } + + async function write(entries: Entry[]): Promise { + await using operation = await DataRootBarrier.enter(filepath) + const temp = `${filepath}.${process.pid}.${crypto.randomUUID()}.tmp` + await fs.mkdir(path.dirname(filepath), { recursive: true }) + try { + const handle = await fs.open(temp, "wx", 0o600) + await handle + .chmod(0o600) + .then(() => handle.writeFile(JSON.stringify(entries, null, 2), "utf8")) + .then(() => handle.sync()) + .finally(() => handle.close()) + await fs.rename(temp, filepath) + const directory = await fs.open(path.dirname(filepath), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + } + } + + function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } + } + + function processEnv(): Record { + const keys = ["PATH", "SYSTEMROOT", "WINDIR", "PATHEXT", "TMP", "TEMP"] + return Object.fromEntries(keys.flatMap((key) => (process.env[key] ? [[key, process.env[key]!]] : []))) + } + + function linuxProcess(stat: string) { + const close = stat.lastIndexOf(")") + if (close < 0) return + const fields = stat + .slice(close + 2) + .trim() + .split(/\s+/) + const ppid = Number(fields[1]) + const pgid = Number(fields[2]) + const started = fields[19] + if (!Number.isSafeInteger(ppid) || ppid < 0 || !Number.isSafeInteger(pgid) || pgid <= 0 || !started) return + return { ppid, pgid, started } + } + + async function linuxProcessFor(pid: number) { + const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => undefined) + return stat ? linuxProcess(stat) : undefined + } + + async function darwinProcess(pid: number) { + if (process.platform !== "darwin") return + const { dlopen, FFIType, ptr } = await import("bun:ffi") + const lib = dlopen("/usr/lib/libproc.dylib", { + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + }) + try { + // PROC_PIDTBSDINFO. The public proc_bsdinfo ABI is 136 bytes on all + // supported 64-bit macOS architectures; its final two uint64 fields are + // start time with microsecond precision. This avoids ps(1)'s one-second + // start-time granularity, which is insufficient for PID-reuse safety. + const info = Buffer.alloc(136) + const size = lib.symbols.proc_pidinfo(pid, 3, 0n, ptr(info), info.length) + if (size !== info.length || info.readUInt32LE(12) !== pid) return + return { + ppid: info.readUInt32LE(16), + pgid: info.readUInt32LE(100), + startedSeconds: info.readBigUInt64LE(120), + startedMicroseconds: info.readBigUInt64LE(128), + } + } finally { + lib.close() + } + } + + /** Stable, hashed OS process-start identity. */ + export async function identity(pid: number): Promise { + const raw = await (async () => { + if (process.platform === "linux") { + const info = await linuxProcessFor(pid) + return info ? `linux:${info.started}` : undefined + } + if (process.platform === "darwin") { + const info = await darwinProcess(pid) + return info ? `darwin:${info.startedSeconds}:${info.startedMicroseconds}` : undefined + } + if (process.platform === "win32") { + return WindowsJob.identity(pid) + } + })() + return raw ? crypto.createHash("sha256").update(raw).digest("hex") : undefined + } + + export async function owns(pid: number, expected: string | undefined): Promise { + if (!expected || !alive(pid)) return false + return (await identity(pid)) === expected + } + + async function processGroup(pid: number): Promise { + if (process.platform === "linux") return (await linuxProcessFor(pid))?.pgid + if (process.platform === "darwin") return (await darwinProcess(pid))?.pgid + } + + async function leadsOwnGroup(pid: number): Promise { + if (process.platform === "win32") return false + return (await processGroup(pid)) === pid + } + + interface Member { + pid: number + identity: string + groupBound: boolean + responsibilityBound: boolean + } + + interface ProcessRow { + pid: number + ppid: number + pgid: number + } + + async function processTable(): Promise { + if (process.platform === "linux") { + const names = await fs.readdir("/proc") + const result: ProcessRow[] = [] + for (const name of names) { + if (!/^\d+$/.test(name)) continue + const pid = Number(name) + const info = await linuxProcessFor(pid) + if (info) result.push({ pid, ppid: info.ppid, pgid: info.pgid }) + } + return result + } + if (process.platform === "darwin") { + const proc = Bun.spawn(["/bin/ps", "-axo", "pid=,ppid=,pgid="], { + env: processEnv(), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Could not enumerate authorized processes: ${stderr.trim()}`) + return stdout + .split("\n") + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter( + ([pid, ppid, pgid]) => + Number.isSafeInteger(pid) && + pid > 0 && + Number.isSafeInteger(ppid) && + ppid >= 0 && + Number.isSafeInteger(pgid) && + pgid > 0, + ) + .map(([pid, ppid, pgid]) => ({ pid, ppid, pgid })) + } + throw new Error(`Durable authority process teardown is unsupported on ${process.platform}`) + } + + /** Capture exact identities for every current group member and live + * descendant. The descendant closure catches a direct setsid()/new-session + * escape while the registered leader remains alive. If the original PID + * now names a different process, the old group has already ceased to exist: + * POSIX cannot reuse a PGID while that process group still has members. */ + async function groupMembers(entry: Entry): Promise { + const currentLeader = await identity(entry.pid) + if (currentLeader && currentLeader !== entry.identity) return [] + const rows = await processTable() + const selected = new Map() + for (const row of rows) { + if (row.pgid === entry.pid) selected.set(row.pid, true) + } + if (currentLeader === entry.identity) { + const descendants = new Set([entry.pid]) + let changed = true + while (changed) { + changed = false + for (const row of rows) { + if (descendants.has(row.pid) || !descendants.has(row.ppid)) continue + descendants.add(row.pid) + selected.set(row.pid, row.pgid === entry.pid) + changed = true + } + } + } + const responsible = new Set( + entry.darwin_responsibility_uniqueid + ? DarwinResponsibility.uniqueMembers(entry.darwin_responsibility_uniqueid) + : [], + ) + for (const pid of responsible) selected.set(pid, selected.get(pid) ?? false) + const members: Member[] = [] + for (const [pid, groupBound] of selected) { + const memberIdentity = await identity(pid) + if (!memberIdentity) continue + if (groupBound && (await processGroup(pid)) !== entry.pid) continue + if (pid === entry.pid && memberIdentity !== entry.identity) return [] + members.push({ pid, identity: memberIdentity, groupBound, responsibilityBound: responsible.has(pid) }) + } + return members + } + + async function signalMember(entry: Entry, member: Member): Promise { + if (!(await owns(member.pid, member.identity))) return false + if (member.groupBound && (await processGroup(member.pid)) !== entry.pid) return false + if ( + member.responsibilityBound && + (!entry.darwin_responsibility_uniqueid || + !DarwinResponsibility.uniquelyOwns(entry.darwin_responsibility_uniqueid, member.pid)) + ) { + return false + } + if (member.pid === entry.pid && member.identity !== entry.identity) return false + // Keep the original leader until last. Its exact identity pins the PGID + // while descendants are signalled and prevents group-number reuse. + try { + process.kill(member.pid, "SIGKILL") + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false + throw error + } + } + + async function teardown(entry: Entry): Promise { + if (process.platform === "win32") { + if (!entry.windows_job) { + throw new Error(`Authorized ${entry.kind} process ${entry.pid} predates Windows Job Object ownership`) + } + const live = await owns(entry.pid, entry.identity) + const terminated = WindowsJob.terminate(entry.windows_job) + if (live && !terminated && (await owns(entry.pid, entry.identity))) { + throw new Error(`Windows Job Object ${entry.windows_job} disappeared while process ${entry.pid} remained alive`) + } + return live || terminated + } + if (!entry.owns_process_group) { + throw new Error(`Authorized ${entry.kind} process ${entry.pid} has no safely reapable process group`) + } + + let signalled = false + for (let attempt = 0; attempt < 100; attempt++) { + const members = await groupMembers(entry) + if (!members.length) return signalled + // Descendants first, exact recorded leader last. A process that exits or + // changes groups between enumeration and the identity recheck is skipped. + members.sort((a, b) => Number(a.pid === entry.pid) - Number(b.pid === entry.pid)) + for (const member of members) signalled = (await signalMember(entry, member)) || signalled + await Bun.sleep(20) + } + const remaining = await groupMembers(entry) + throw new Error( + `Authorized ${entry.kind} process group ${entry.pid} did not exit (${remaining.length} members remain)`, + ) + } + + export async function register(input: { + id: string + kind: Kind + pid: number + expectedIdentity?: string + windowsRelease?: string + projectID: string + sessionID: string + authorityGeneration: string + }): Promise { + if ((process.platform === "win32" || process.platform === "darwin") && !input.windowsRelease) { + throw new Error( + `Authorized ${input.kind} child ${input.pid} was not launched behind the ${process.platform === "win32" ? "Windows Job Object" : "macOS responsibility"} registration gate`, + ) + } + if (process.platform === "darwin" && !DarwinResponsibility.available()) { + throw new Error("macOS responsibility APIs are unavailable; refusing durable process registration") + } + const processIdentity = await identity(input.pid) + if (!processIdentity) { + if (!alive(input.pid)) return false + throw new Error(`Could not establish a safe process identity for authorized child ${input.pid}`) + } + if (input.expectedIdentity && input.expectedIdentity !== processIdentity) { + throw new Error(`Authorized ${input.kind} child ${input.pid} changed identity before durable registration`) + } + const ownsGroup = process.platform === "win32" ? false : await leadsOwnGroup(input.pid) + if (process.platform !== "win32" && !ownsGroup) { + throw new Error( + `Authorized ${input.kind} child ${input.pid} is not its own process-group leader; refusing an unreapable spawn`, + ) + } + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const index = entries.findIndex((entry) => entry.id === input.id) + // A duplicate durable ID must never orphan the previous Job handle/tree. + // Reap it while the shared ledger lease prevents a competing replacement. + if ((process.platform === "win32" || process.platform === "darwin") && index >= 0) { + await teardown(entries[index]!) + } + let darwinResponsibility: string | undefined + const windowsJob = + process.platform === "win32" + ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) + : undefined + const next: Entry = { + version: 1, + id: input.id, + kind: input.kind, + pid: input.pid, + identity: processIdentity, + owns_process_group: ownsGroup, + ...(windowsJob ? { windows_job: windowsJob } : {}), + owner_pid: process.pid, + project_id: input.projectID, + session_id: input.sessionID, + authority_generation: input.authorityGeneration, + created_at: new Date().toISOString(), + } + if (index < 0) entries.push(next) + else entries[index] = next + await write(entries).catch((error) => { + if (windowsJob) WindowsJob.terminate(windowsJob) + throw error + }) + if (windowsJob && input.windowsRelease) { + try { + WindowsJob.release(input.windowsRelease, input.pid) + } catch (error) { + await teardown(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (process.platform === "darwin" && input.windowsRelease) { + try { + await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + for (let attempt = 0; attempt < 3_000; attempt++) { + if (!(await owns(input.pid, processIdentity))) break + if (DarwinResponsibility.responsible(input.pid) === input.pid) { + darwinResponsibility = DarwinResponsibility.unique(input.pid) + if (darwinResponsibility) break + } + if (attempt === 2_999) { + throw new Error(`Authorized ${input.kind} child ${input.pid} did not become a macOS responsibility root`) + } + await Bun.sleep(10) + } + } catch (error) { + await teardown(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility) { + next.darwin_responsibility_uniqueid = darwinResponsibility + const position = entries.findIndex((entry) => entry.id === input.id) + if (position >= 0) entries[position] = next + await write(entries) + try { + await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }) + } catch (error) { + await teardown(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { + await teardown(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw new Error(`Authorized ${input.kind} child ${input.pid} failed macOS responsibility handoff`) + } + // Persist first, then close the observation window. If the leader exited + // during registration, durable ownership already exists; tear down any + // surviving same-group children before returning a failed spawn. + if ( + !(await owns(input.pid, processIdentity)) || + (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || + (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) + ) { + await teardown(next) + await write(entries.filter((entry) => entry.id !== input.id)) + return false + } + return true + } + + /** A leader can exit while background work remains in its process group. + * Normal completion therefore tears down and verifies the whole group before + * dropping durable ownership. */ + export async function complete(id: string): Promise { + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const entry = entries.find((item) => item.id === id) + if (!entry) return true + if (await owns(entry.pid, entry.identity)) return false + await teardown(entry) + await write(entries.filter((item) => item.id !== id)) + return true + } + + /** Kill identity-matched children even when their owning server is gone. */ + export async function revoke(scope: Scope = {}): Promise { + await using lease = await FileLease.acquire(lockpath) + const entries = await read() + const retained: Entry[] = [] + let killed = 0 + const failures: unknown[] = [] + for (const entry of entries) { + const match = + (!scope.id || entry.id === scope.id) && + (!scope.kind || entry.kind === scope.kind) && + (!scope.projectID || entry.project_id === scope.projectID) && + (!scope.sessionID || entry.session_id === scope.sessionID) && + (!scope.authorityGeneration || entry.authority_generation === scope.authorityGeneration) + if (!match) { + retained.push(entry) + continue + } + try { + if (await teardown(entry)) killed++ + } catch (error) { + retained.push(entry) + failures.push(error) + } + } + await write(retained) + if (failures.length) throw new AggregateError(failures, "Authorized child revocation failed") + return killed + } + + export function pathForTests(): string { + return filepath + } +} diff --git a/backend/cli/src/project/authority-signal.ts b/backend/cli/src/project/authority-signal.ts new file mode 100644 index 00000000..a039d32e --- /dev/null +++ b/backend/cli/src/project/authority-signal.ts @@ -0,0 +1,180 @@ +import z from "zod" +import path from "node:path" +import { Global } from "@/global" +import { Storage } from "@/storage/storage" +import { FileLease } from "@/util/file-lease" +import { Log } from "@/util/log" + +/** + * Minimal durable authority-change signal shared by every OpenScience process + * using one data directory. It deliberately stores only routing identifiers — + * never permission payloads, paths, prompts, or credentials. + */ +export namespace AuthoritySignal { + const log = Log.create({ service: "authority.signal" }) + + export const Event = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("trust"), + projectID: z.string(), + denied: z.boolean(), + }), + z.object({ + kind: z.literal("filesystem"), + projectID: z.string(), + sessionID: z.string(), + scope: z.enum(["once", "session", "project", "installation"]), + }), + ]) + export type Event = z.infer + + const PendingEvent = z.object({ + revision: z.number().int().positive(), + event: Event, + }) + + const State = z.object({ + version: z.literal(1), + revision: z.number().int().nonnegative(), + pending: z.boolean().default(false), + time: z.number().int().positive(), + origin: z.number().int().positive(), + event: Event, + backlog: PendingEvent.array().default([]), + }) + type State = z.infer + + const key = ["authority", "revision"] + const lock = () => path.join(Global.Path.data, "authority", "spawn.lock") + // Governed launches are deliberately serialized against authority changes. + // A single kernel ready handshake may take up to 15s. FileLease resets this + // bounded wait only when the exact owner token changes, so healthy parallel + // launches can advance while one wedged owner still fails closed. + const spawnOwnerWait = 30_000 + + /** + * Serialize an authority mutation with the final authority check, process + * creation, and owner registration performed by every runtime. A mutation + * that wins this lease is durable before a later spawn can proceed; a spawn + * that wins first is registered before the mutation's revokers run. + */ + export async function exclusive(action: () => Promise): Promise { + await using lease = await FileLease.acquire(lock(), spawnOwnerWait) + // Await inside this lexical scope so `await using` cannot dispose the + // interprocess lease before the spawn/mutation callback has settled. + return await action() + } + + async function current() { + return Storage.read(key) + .then((value) => State.parse(value)) + .catch((error) => { + if (Storage.NotFoundError.isInstance(error)) return undefined + throw error + }) + } + + export async function publish(event: Event) { + const parsed = Event.parse(event) + return Storage.upsert(key, (value) => { + const previous = value ? State.parse(value) : undefined + const backlog = [...(previous?.backlog ?? [])] + if (previous?.pending && !backlog.some((item) => item.revision === previous.revision)) { + backlog.push({ revision: previous.revision, event: previous.event }) + } + return { + version: 1, + revision: (previous?.revision ?? 0) + 1, + pending: true, + time: Date.now(), + origin: process.pid, + event: parsed, + backlog, + } + }) + } + + /** Mark one mutation's reaper work complete without erasing a newer event. + * A process that dies before this acknowledgement leaves `pending=true`, so + * the next watcher applies the durable denial before accepting new work. */ + export async function settle(revision: number): Promise { + await Storage.update(key, (draft) => { + const current = State.parse(draft) + draft.backlog = current.backlog.filter((item) => item.revision !== revision) + if (current.revision === revision && current.pending) draft.pending = false + }) + } + + export async function pending(event: Event): Promise { + const expected = Event.parse(event) + const state = await current() + if (!state) return + const matches = [ + ...state.backlog, + ...(state.pending ? [{ revision: state.revision, event: state.event }] : []), + ].filter((item) => JSON.stringify(item.event) === JSON.stringify(expected)) + return matches.at(-1)?.revision + } + + export type Change = { type: "event"; revision: number; event: Event } | { type: "resync"; revision: number } + + /** Poll a tiny revision record. A skipped revision causes a conservative + * resync signal because the last event alone cannot describe every affected + * process. The timer is unref'd and disposed with its project instance. */ + export async function watch(handler: (change: Change) => Promise, pollMs = 200) { + const initial = await current() + const firstPending = initial + ? Math.min(...initial.backlog.map((item) => item.revision), ...(initial.pending ? [initial.revision] : [])) + : Number.POSITIVE_INFINITY + let revision = Number.isFinite(firstPending) ? Math.max(0, firstPending - 1) : (initial?.revision ?? 0) + let active = true + let polling = false + const poll = async () => { + if (!active || polling) return + polling = true + try { + const next = await current() + if (!next || next.revision <= revision) return + + const pending = [...next.backlog, ...(next.pending ? [{ revision: next.revision, event: next.event }] : [])] + .filter((item) => item.revision > revision) + .toSorted((a, b) => a.revision - b.revision) + for (const item of pending) { + if (item.revision > revision + 1) { + await handler({ type: "resync", revision: item.revision - 1 }) + } + const handled = await handler({ type: "event", revision: item.revision, event: item.event }) + if (handled !== false) await settle(item.revision) + revision = item.revision + } + + if (next.revision <= revision) return + const previous = revision + const change: Change = + next.revision !== previous + 1 + ? { type: "resync", revision: next.revision } + : { type: "event", revision: next.revision, event: next.event } + if (next.origin === process.pid && !next.pending) { + revision = next.revision + return + } + const handled = await handler(change) + if (next.pending && handled !== false) await settle(next.revision) + revision = next.revision + } catch (error) { + log.error("failed to poll authority revision", { error }) + } finally { + polling = false + } + } + const timer = setInterval(() => void poll(), pollMs) + ;(timer as { unref?: () => void }).unref?.() + return { + async [Symbol.asyncDispose]() { + active = false + clearInterval(timer) + while (polling) await new Promise((resolve) => setTimeout(resolve, 5)) + }, + } + } +} diff --git a/backend/cli/src/project/bootstrap.ts b/backend/cli/src/project/bootstrap.ts index b6b52ff1..1d0c5a95 100644 --- a/backend/cli/src/project/bootstrap.ts +++ b/backend/cli/src/project/bootstrap.ts @@ -21,17 +21,63 @@ import { ProjectTrust } from "./trust" import { Pty } from "../pty" import { KernelRuntime } from "@/science/kernel/registry" import { GlobalBus } from "@/bus/global" +import { AuthoritySignal } from "./authority-signal" +import { CommandRuntime } from "@/science/command/registry" +import { AuthorityProcessLedger } from "./authority-process" +import { MCP } from "@/mcp" +import { CredentialProcessLedger } from "@/credentials/process-ledger" +import { Agent } from "@/agent/agent" +import { ToolRegistry } from "@/tool/registry" + +async function invalidateProjectTokenCache(projectID: string) { + const { Provider } = await import("@/provider/provider") + Provider.invalidateTokenCache(projectID) +} + +/** + * Project executable definitions are memoized independently from Config. + * Revocation must evict them before the mutation is acknowledged; otherwise a + * long-running instance can keep returning commands, tools, skills, agents, or + * plugin auth/hooks loaded while the project was trusted. These operations are + * synchronous cache swaps (or an in-process event for Skill), so they never + * acquire the AuthoritySignal lease already held by ProjectTrust.update. + */ +async function invalidateProjectExecutionCaches() { + Command.invalidate() + ToolRegistry.invalidate() + Agent.invalidate() + Plugin.invalidate() + const providerAuth = import("@/provider/auth").then(({ ProviderAuth }) => ProviderAuth.invalidate()) + await Promise.all([Skill.invalidate(), providerAuth]) +} async function stopSessions(sessionIDs: string[]) { const sessions = [...new Set(sessionIDs)] + const projectID = Instance.project.id + const biology = import("@/tool/biology/notebook").then((module) => + Promise.all(sessions.map((sessionID) => module.releaseBiologySession(projectID, sessionID))), + ) const jobs = import("../compute/jobs").then((module) => Promise.all(sessions.map((sessionID) => module.ComputeJobs.cancelSession(sessionID))), ) await Promise.all([ ...sessions.map((sessionID) => Pty.releaseSession(sessionID)), ...sessions.map((sessionID) => KernelRuntime.releaseSession(sessionID)), + ...sessions.map((sessionID) => CommandRuntime.stopSession(projectID, sessionID)), + biology, jobs, ]) + await Promise.all(sessions.map((sessionID) => AuthorityProcessLedger.revoke({ projectID, sessionID }))) +} + +async function stopFilesystem(sessionID: string, scope: SessionFilesystem.Scope) { + const projectID = Instance.project.id + await stopSessions(await affected(sessionID, scope)) + if (scope === "project") await AuthorityProcessLedger.revoke({ projectID }) + // Installation grants authorize every project. Reap the global durable + // ledger as well as each live instance's local runtimes so a killed owner + // from an unloaded project cannot retain the revoked authority. + if (scope === "installation") await AuthorityProcessLedger.revoke() } async function affected(sessionID: string, scope: SessionFilesystem.Scope) { @@ -55,7 +101,7 @@ const filesystemSync = Instance.state( if (payload.data.grant.scope !== "installation" && payload.data.projectID !== projectID) return Instance.provide({ directory, - fn: async () => stopSessions(await affected(payload.data.sessionID, payload.data.grant.scope)), + fn: async () => stopFilesystem(payload.data.sessionID, payload.data.grant.scope), }).catch((error) => Log.Default.error("failed to apply filesystem authority change", { error, directory })) } GlobalBus.on("event", handler) @@ -66,6 +112,70 @@ const filesystemSync = Instance.state( }, ) +const authoritySync = Instance.state( + async () => { + const directory = Instance.directory + const projectID = Instance.project.id + return AuthoritySignal.watch(async (change) => { + return Instance.provide({ + directory, + fn: async () => { + if (change.type === "resync") { + const sessions = [] + for await (const session of Session.list()) sessions.push(session.id) + await Promise.all([ + stopSessions(sessions), + LSP.dispose(), + MCP.disposeLocal(), + invalidateProjectExecutionCaches(), + ]) + await Promise.all([ + AuthorityProcessLedger.revoke({ projectID }), + CredentialProcessLedger.revoke({ kind: "mcp", projectID }), + CredentialProcessLedger.revoke({ kind: "provider", projectID }), + invalidateProjectTokenCache(projectID), + ]) + return true + } + const event = change.event + if (event.kind === "trust") { + if (event.projectID !== projectID) return false + if (!event.denied) { + await invalidateProjectExecutionCaches() + return true + } + const jobs = import("../compute/jobs").then((module) => module.ComputeJobs.cancelProject(projectID)) + const biology = import("@/tool/biology/notebook").then((module) => module.releaseBiologyProject(projectID)) + await Promise.all([ + Pty.releaseAll(), + KernelRuntime.releaseProject(projectID), + CommandRuntime.stopProject(projectID), + LSP.dispose(), + MCP.disposeLocal(), + invalidateProjectExecutionCaches(), + biology, + jobs, + ]) + await Promise.all([ + AuthorityProcessLedger.revoke({ projectID }), + CredentialProcessLedger.revoke({ kind: "mcp", projectID }), + CredentialProcessLedger.revoke({ kind: "provider", projectID }), + invalidateProjectTokenCache(projectID), + ]) + return true + } + if (event.scope !== "installation" && event.projectID !== projectID) return false + await stopFilesystem(event.sessionID, event.scope) + return true + }, + }) + }) + }, + async (watcher) => { + await watcher[Symbol.asyncDispose]() + }, +) + export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() @@ -77,6 +187,7 @@ export async function InstanceBootstrap() { Snapshot.init() Truncate.init() filesystemSync() + await authoritySync() // RSI lifecycle: archive unused learned skills, log high performers RSILifecycle.startupCheck().catch(() => {}) @@ -107,23 +218,58 @@ export async function InstanceBootstrap() { const jobs = import("../compute/jobs").then((module) => module.ComputeJobs.cancelSession(payload.properties.info.id), ) + const biology = import("@/tool/biology/notebook").then((module) => + module.releaseBiologySession(Instance.project.id, payload.properties.info.id), + ) await Promise.all([ Pty.releaseSession(payload.properties.info.id), KernelRuntime.removeSession(Instance.project.id, payload.properties.info.id), + CommandRuntime.stopSession(Instance.project.id, payload.properties.info.id), + biology, jobs, ]) + await AuthorityProcessLedger.revoke({ + projectID: Instance.project.id, + sessionID: payload.properties.info.id, + }) }) // Process authority is revision-bound. Trust revocation stops every live // project process. Filesystem changes stop every process covered by their // session, project, or installation scope, including other live instances. Bus.subscribe(ProjectTrust.Event.Changed, async (payload) => { - if (payload.properties.status.canExecuteProjectCode) return + if (payload.properties.status.canExecuteProjectCode) { + await invalidateProjectExecutionCaches() + return + } const jobs = import("../compute/jobs").then((module) => module.ComputeJobs.cancelProject(Instance.project.id)) - await Promise.all([Pty.releaseAll(), ...KernelRuntime.list().map((kernel) => KernelRuntime.release(kernel)), jobs]) + const biology = import("@/tool/biology/notebook").then((module) => + module.releaseBiologyProject(Instance.project.id), + ) + await Promise.all([ + Pty.releaseAll(), + KernelRuntime.releaseProject(Instance.project.id), + CommandRuntime.stopProject(Instance.project.id), + LSP.dispose(), + MCP.disposeLocal(), + invalidateProjectExecutionCaches(), + biology, + jobs, + ]) + await Promise.all([ + AuthorityProcessLedger.revoke({ projectID: Instance.project.id }), + CredentialProcessLedger.revoke({ kind: "mcp", projectID: Instance.project.id }), + CredentialProcessLedger.revoke({ kind: "provider", projectID: Instance.project.id }), + invalidateProjectTokenCache(Instance.project.id), + ]) }) Bus.subscribe(SessionFilesystem.Event.Changed, async (payload) => { - await stopSessions(await affected(payload.properties.sessionID, payload.properties.grant.scope)) + await stopFilesystem(payload.properties.sessionID, payload.properties.grant.scope) }) + + // Tombstoned deletions are deliberately resumed only after all runtime + // cleanup handlers above are installed, so recovery has the same strict + // acknowledgment contract as the original request. + await Session.resumeDeleting() } diff --git a/backend/cli/src/project/execution.ts b/backend/cli/src/project/execution.ts index 53c37e24..a3bf3b3f 100644 --- a/backend/cli/src/project/execution.ts +++ b/backend/cli/src/project/execution.ts @@ -43,6 +43,7 @@ export namespace ExecutionAuthority { grantRevision: z.number().int().positive(), generation: z.string(), workspace: z.string(), + readable: z.array(z.string()), writable: z.array(z.string()), sandbox: z.object({ enabled: z.boolean(), @@ -90,8 +91,11 @@ export namespace ExecutionAuthority { const unavailable = sandbox.enabled && !sandbox.available && sandbox.onUnavailable === "error" const reason = untrusted ? "project_untrusted" : unavailable ? "sandbox_unavailable" : "allowed" const mode = untrusted || unavailable ? "read_only" : sandbox.enabled ? "sandboxed" : "host" - const writable = await SessionFilesystem.processWriteRoots(input.sessionID) - const workspace = await SessionFilesystem.workspace(input.sessionID) + const [readable, writable, workspace] = await Promise.all([ + SessionFilesystem.processReadRoots(input.sessionID), + SessionFilesystem.processWriteRoots(input.sessionID), + SessionFilesystem.workspace(input.sessionID), + ]) const generation = crypto .createHash("sha256") .update( @@ -116,6 +120,7 @@ export namespace ExecutionAuthority { grantRevision: filesystem.revision, generation, workspace, + readable, writable, sandbox, remediation: trust.remediation, diff --git a/backend/cli/src/project/project.ts b/backend/cli/src/project/project.ts index bae08343..3f0ef588 100644 --- a/backend/cli/src/project/project.ts +++ b/backend/cli/src/project/project.ts @@ -18,6 +18,8 @@ import { NamedError } from "@synsci/util/error" import { Lock } from "@/util/lock" import type { SessionFilesystem } from "@/session/filesystem" import type { SessionWorkspace } from "@/session/workspace" +import { FileLease } from "@/util/file-lease" +import { Global } from "@/global" export namespace Project { const log = Log.create({ service: "project" }) @@ -300,56 +302,65 @@ export namespace Project { } }) - // Identity selection and migration must be serialized for a canonical root. - // Otherwise two simultaneous first opens can create competing opaque ids, - // or one opener can observe a legacy record while another is removing it. - using _ = await Lock.write(`project:${worktree}`) - - const found = await records(worktree) - const opaque = found.find((record) => record.id.startsWith("prj_")) - const source = opaque ?? found[0] - const id = opaque?.id ?? createID() - const current = found - .filter((record) => record.id !== source?.id) - .reduce( - (result, record) => merge(result, record.project), - source - ? { - ...source.project, - id, - sandboxes: [...(source.project.sandboxes ?? [])], - } - : { - id, - worktree, - vcs: vcs as Info["vcs"], - sandboxes: [], - time: { - created: Date.now(), - updated: Date.now(), - }, - }, + const result = await iife(async () => { + // Always take the process-local lock before the durable lease. The pair + // covers only identity selection and legacy adoption: two server + // processes cannot mint competing ids and then delete each other's live + // session records, while unrelated icon discovery runs after release. + using local = await Lock.write(`project:${worktree}`) + const digest = crypto.createHash("sha256").update(worktree).digest("hex") + await using durable = await FileLease.acquire( + path.join(Global.Path.data, "project-leases", `${digest}.lock`), + 120_000, ) - if (Flag.OPENSCIENCE_EXPERIMENTAL_ICON_DISCOVERY) discover(current) + const found = await records(worktree) + const opaque = found.find((record) => record.id.startsWith("prj_")) + const source = opaque ?? found[0] + const id = opaque?.id ?? createID() + const current = found + .filter((record) => record.id !== source?.id) + .reduce( + (result, record) => merge(result, record.project), + source + ? { + ...source.project, + id, + sandboxes: [...(source.project.sandboxes ?? [])], + } + : { + id, + worktree, + vcs: vcs as Info["vcs"], + sandboxes: [], + time: { + created: Date.now(), + updated: Date.now(), + }, + }, + ) + + const result: Info = { + ...current, + worktree, + vcs: vcs as Info["vcs"], + time: { + ...current.time, + updated: Date.now(), + }, + } + if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) + result.sandboxes = [ + ...new Set( + result.sandboxes.filter((directory) => canonicalize(directory) !== result.worktree && existsSync(directory)), + ), + ] + await Storage.write(["project", id], result) + await adoptLegacy(id, worktree, found) + return result + }) - const result: Info = { - ...current, - worktree, - vcs: vcs as Info["vcs"], - time: { - ...current.time, - updated: Date.now(), - }, - } - if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) - result.sandboxes = [ - ...new Set( - result.sandboxes.filter((directory) => canonicalize(directory) !== result.worktree && existsSync(directory)), - ), - ] - await Storage.write(["project", id], result) - await adoptLegacy(id, worktree, found) + if (Flag.OPENSCIENCE_EXPERIMENTAL_ICON_DISCOVERY) discover(result) GlobalBus.emit("event", { payload: { type: Event.Updated.type, diff --git a/backend/cli/src/project/trust.ts b/backend/cli/src/project/trust.ts index eecc2fd1..ebf8f982 100644 --- a/backend/cli/src/project/trust.ts +++ b/backend/cli/src/project/trust.ts @@ -5,6 +5,7 @@ import { Bus } from "../bus" import { BusEvent } from "../bus/bus-event" import { Storage } from "../storage/storage" import { Project } from "./project" +import { AuthoritySignal } from "./authority-signal" export namespace ProjectTrust { export const Capability = z.enum([ @@ -14,6 +15,7 @@ export namespace ProjectTrust { "project_mcp", "project_formatter", "project_lsp", + "publication_export", "provider_token_command", "provider_module", "startup_script", @@ -23,6 +25,7 @@ export namespace ProjectTrust { "local_job", "remote_job", "package_install", + "repository", ]) export type Capability = z.infer @@ -121,7 +124,7 @@ export namespace ProjectTrust { return { code: "trust_project_required" as const, message: - "Review this project's local configuration and code before allowing plugins, skills, MCP servers, formatters, LSP commands, provider token commands or modules, dependency installation, or startup scripts.", + "Review this project's local configuration and code before allowing plugins, skills, MCP servers, formatters, LSP commands, publication exporters, provider token commands or modules, dependency installation, repository commands, or startup scripts.", method: "PUT" as const, path: `/project/${project.id}/trust`, body: { @@ -140,15 +143,27 @@ export namespace ProjectTrust { export async function status(project: Project.Info): Promise { const canonical = root(project) const saved = await record(project) - if (saved?.root !== canonical || saved.state !== "revoked") { + if (!saved || saved.root !== canonical) { return { projectID: project.id, root: canonical, revision: saved?.revision ?? 1, - state: "trusted", + state: "untrusted", source: saved ? "persisted" : "default", - canExecuteProjectCode: true, + canExecuteProjectCode: false, time: saved?.time, + remediation: remediation(project), + } + } + if (saved.state === "trusted") { + return { + projectID: project.id, + root: canonical, + revision: saved.revision, + state: "trusted", + source: "persisted", + canExecuteProjectCode: true, + time: saved.time, } } return { @@ -168,49 +183,79 @@ export namespace ProjectTrust { } export async function update(project: Project.Info, input: Update): Promise { - const canonical = root(project) - const previous = await record(project) - const now = Date.now() - const revision = (previous?.revision ?? 1) + 1 - if (input.trusted) { - const received = Project.canonicalize(input.root) - if (received !== canonical) { - throw new RootMismatchError({ - projectID: project.id, - expected: canonical, - received, + return AuthoritySignal.exclusive(async () => { + const canonical = root(project) + const now = Date.now() + if (input.trusted) { + const received = Project.canonicalize(input.root) + if (received !== canonical) { + throw new RootMismatchError({ + projectID: project.id, + expected: canonical, + received, + }) + } + let changed = false + await Storage.upsert>(key(project), (raw) => { + const previous = raw ? Record.parse(raw) : undefined + if (previous?.root === canonical && previous.state === "trusted") return previous + changed = true + return { + projectID: project.id, + root: canonical, + revision: (previous?.revision ?? 1) + 1, + state: "trusted", + time: { + updated: now, + trusted: now, + revoked: previous?.time.revoked, + }, + } }) + const result = await status(project) + if (!changed) { + const revision = await AuthoritySignal.pending({ kind: "trust", projectID: project.id, denied: false }) + if (!revision) return result + await Bus.publish(Event.Changed, { status: result }) + await AuthoritySignal.settle(revision) + return result + } + const signal = await AuthoritySignal.publish({ kind: "trust", projectID: project.id, denied: false }) + await Bus.publish(Event.Changed, { status: result }) + await AuthoritySignal.settle(signal.revision) + return result } - await Storage.write>(key(project), { - projectID: project.id, - root: canonical, - revision, - state: "trusted", - time: { - updated: now, - trusted: now, - revoked: previous?.time.revoked, - }, + + let changed = false + await Storage.upsert>(key(project), (raw) => { + const previous = raw ? Record.parse(raw) : undefined + if (previous?.root === canonical && previous.state === "revoked") return previous + changed = true + return { + projectID: project.id, + root: canonical, + revision: (previous?.revision ?? 1) + 1, + state: "revoked", + time: { + updated: now, + trusted: previous?.time.trusted, + revoked: now, + }, + } }) const result = await status(project) + if (!changed) { + const revision = await AuthoritySignal.pending({ kind: "trust", projectID: project.id, denied: true }) + if (!revision) return result + await Bus.publish(Event.Changed, { status: result }) + await AuthoritySignal.settle(revision) + return result + } + const signal = await AuthoritySignal.publish({ kind: "trust", projectID: project.id, denied: true }) await Bus.publish(Event.Changed, { status: result }) + await AuthoritySignal.settle(signal.revision) return result - } - - await Storage.write>(key(project), { - projectID: project.id, - root: canonical, - revision, - state: "revoked", - time: { - updated: now, - trusted: previous?.time.trusted, - revoked: now, - }, }) - const result = await status(project) - await Bus.publish(Event.Changed, { status: result }) - return result } export async function require(project: Project.Info, capability: Capability) { diff --git a/backend/cli/src/provider/auth.ts b/backend/cli/src/provider/auth.ts index e76cae3c..45872d0d 100644 --- a/backend/cli/src/provider/auth.ts +++ b/backend/cli/src/provider/auth.ts @@ -6,9 +6,10 @@ import { fn } from "@/util/fn" import type { AuthOuathResult } from "@synsci/plugin" import { NamedError } from "@synsci/util/error" import { Auth } from "@/auth" +import { State } from "@/project/state" export namespace ProviderAuth { - const state = Instance.state(async () => { + const compute = async () => { const methods = pipe( await Plugin.list(), filter((x) => x.auth?.provider !== undefined), @@ -16,7 +17,13 @@ export namespace ProviderAuth { fromEntries(), ) return { methods, pending: {} as Record } - }) + } + + const state = Instance.state(compute) + + export function invalidate() { + State.clear(Instance.directory, compute) + } export const Method = z .object({ diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 9c81bb7b..63a168f8 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -16,6 +16,8 @@ import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { OpenScience } from "../openscience" import { isAtlasProxyURL, managedOpenRouterBaseURL } from "../openscience/synced-env-policy" +import { CredentialLifecycle } from "../credentials/lifecycle" +import { ProviderTokenCommand } from "./token-command" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -1683,6 +1685,7 @@ export namespace Provider { // Returns the memoised state, creating it on first call or after invalidate(). async function state() { + await CredentialLifecycle.ensureFresh() const directory = Instance.directory const trusted = await ProjectTrust.allowed(Instance.project) if (_stateCacheDirectory !== directory || _stateCacheTrust !== trusted) { @@ -1732,15 +1735,38 @@ export namespace Provider { // its stdout as `Authorization: Bearer `, re-minting shortly before the // token's JWT exp. Module-level so the cache + single-flight are shared across the // (memoized) SDK instances rather than re-run per request. - const tokenCache = new Map() - const tokenInflight = new Map>() + type TokenScope = { + projectID: string + providerID: string + command: string + endpoint: string + } + type TokenCacheEntry = TokenScope & { token: string; expires: number } + type TokenInflightEntry = TokenScope & { promise: Promise } + + const tokenCache = new Map() + const tokenInflight = new Map() + let tokenGeneration = 0 + + export function invalidateTokenCache(projectID?: string): void { + // Advancing the generation prevents an already-running mint from + // repopulating a cache that was invalidated while the helper was active. + tokenGeneration++ + if (!projectID) { + tokenCache.clear() + tokenInflight.clear() + return + } + for (const [key, entry] of tokenCache) { + if (entry.projectID === projectID) tokenCache.delete(key) + } + for (const [key, entry] of tokenInflight) { + if (entry.projectID === projectID) tokenInflight.delete(key) + } + } async function projectToken(model: Model, command: string) { - const config = await Config.get() - const declared = config.provider?.[model.providerID]?.options?.tokenCommand - if (declared !== command) return false - const executable = await Config.getExecution() - return executable.provider?.[model.providerID]?.options?.tokenCommand !== command + return Config.projectControlsProviderToken(model.providerID, command) } async function projectModule(model: Model) { @@ -1753,22 +1779,26 @@ export namespace Provider { return configured(await Config.getExecution()) !== model.api.npm } - async function mintToken(command: string): Promise { - const cached = tokenCache.get(command) + async function mintToken(model: Model, command: string, endpoint: string, projectDeclared: boolean): Promise { + // A token command is evaluated relative to the active project and its + // result is sent to one provider endpoint. Command text alone is therefore + // not an authority boundary: two projects may intentionally use the same + // command while resolving different files from different working trees. + const scope: TokenScope = { + projectID: Instance.project.id, + providerID: model.providerID, + command, + endpoint, + } + const key = JSON.stringify(scope) + const cached = tokenCache.get(key) // Re-mint a minute early so an in-flight request never ships an expired token. if (cached && cached.expires > Date.now() + 60_000) return cached.token - const pending = tokenInflight.get(command) - if (pending) return pending + const pending = tokenInflight.get(key) + if (pending) return pending.promise + const generation = tokenGeneration const run = (async () => { - const proc = Bun.spawn(["sh", "-c", command], { stdout: "pipe", stderr: "pipe" }) - const [out, err, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - const token = out.trim() - if (code !== 0) throw new Error(`tokenCommand exited ${code}: ${err.trim() || "no stderr"}`) - if (!token) throw new Error("tokenCommand produced no output") + const token = await ProviderTokenCommand.run({ command, projectDeclared }) // Decode a JWT exp (seconds) so we can re-mint just before it lapses; a // non-JWT token has no exp, so expire it immediately (re-mint every request). const claims = token.split(".") @@ -1780,10 +1810,14 @@ export namespace Provider { /* not a JWT — leave exp 0 */ } } - tokenCache.set(command, { token, expires: exp ? exp * 1000 : 0 }) + if (generation === tokenGeneration) { + tokenCache.set(key, { ...scope, token, expires: exp ? exp * 1000 : 0 }) + } return token - })().finally(() => tokenInflight.delete(command)) - tokenInflight.set(command, run) + })().finally(() => { + if (tokenInflight.get(key)?.promise === run) tokenInflight.delete(key) + }) + tokenInflight.set(key, { ...scope, promise: run }) return run } @@ -1858,10 +1892,12 @@ export namespace Provider { // Headers.set is case-insensitive, so it replaces the placeholder key the // SDK attached at construction. if (tokenCommand) { - if (await projectToken(model, tokenCommand)) { + const projectDeclared = await projectToken(model, tokenCommand) + if (projectDeclared) { await ProjectTrust.require(Instance.project, "provider_token_command") } - const token = await mintToken(tokenCommand) + const endpoint = String(options["baseURL"] ?? model.api.url ?? "") + const token = await mintToken(model, tokenCommand, endpoint, projectDeclared) const headers = new Headers(opts.headers as HeadersInit | undefined) headers.set("authorization", `Bearer ${token}`) opts.headers = headers @@ -2112,3 +2148,8 @@ export namespace Provider { }), ) } + +CredentialLifecycle.onRefresh(() => { + Provider.invalidateTokenCache() + Provider.invalidate() +}) diff --git a/backend/cli/src/provider/token-command.ts b/backend/cli/src/provider/token-command.ts new file mode 100644 index 00000000..d5757d4e --- /dev/null +++ b/backend/cli/src/provider/token-command.ts @@ -0,0 +1,358 @@ +import path from "node:path" +import { spawn, type ChildProcess } from "node:child_process" +import { Config } from "../config/config" +import { CredentialLifecycle } from "../credentials/lifecycle" +import { CredentialProcessLedger } from "../credentials/process-ledger" +import { OpenScience } from "../openscience" +import { ProcessIdentity } from "../process/process-identity" +import { WindowsJobLauncher } from "../process/windows-job-launcher" +import { AuthoritySignal } from "../project/authority-signal" +import { Instance } from "../project/instance" +import { ProjectTrust } from "../project/trust" +import { Sandbox } from "../sandbox/sandbox" +import { Shell } from "../shell/shell" + +/** + * Governed execution boundary for provider `tokenCommand` helpers. + * + * A token helper is project-controlled code at the exact moment it can mint a + * bearer credential. It therefore gets neither the server's ambient secrets + * nor an unowned process. The command is admitted under the trust and + * credential revision barriers, sandboxed with the machine policy, durably + * registered before its launcher gate opens, and bounded in time and output. + */ +export namespace ProviderTokenCommand { + export const DEFAULT_TIMEOUT_MS = 15_000 + export const MAX_STDOUT_BYTES = 64 * 1024 + export const MAX_STDERR_BYTES = 32 * 1024 + + const POSIX_ENV = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "TMPDIR", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AZURE_CONFIG_DIR", + "CLOUDSDK_CONFIG", + "CLOUDSDK_ACTIVE_CONFIG_NAME", + "GH_HOST", + "KUBECONFIG", + ]) + const WINDOWS_ENV = new Set([ + ...POSIX_ENV, + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + ]) + + interface ActiveState { + projectID: string + ids: Set + } + + const active = Instance.state( + () => ({ projectID: Instance.project.id, ids: new Set() }), + async (state) => { + const results = await Promise.allSettled( + [...state.ids].map((id) => + CredentialProcessLedger.revoke({ id, kind: "provider", projectID: state.projectID }), + ), + ) + state.ids.clear() + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) throw new AggregateError(failures, "Provider token commands could not be revoked") + }, + ) + + export interface RunOptions { + command: string + projectDeclared: boolean + timeoutMs?: number + maxStdoutBytes?: number + maxStderrBytes?: number + } + + interface Launched { + child: ChildProcess + completion: Promise<{ code: number | null; signal: NodeJS.Signals | null }> + id: string + state: ActiveState + sandbox: Sandbox.Plan + } + + /** A deliberately small environment for credential-minting helpers. Cloud + * profile selectors and config paths are allowed; provider/API secret vars, + * dynamic-loader injection, language startup injection, and OpenScience + * control-plane variables are not. */ + export function environment(source: NodeJS.ProcessEnv = process.env): Record { + const allowed = process.platform === "win32" ? WINDOWS_ENV : POSIX_ENV + const result: Record = {} + for (const [key, value] of Object.entries(source)) { + if (!value) continue + const normalized = process.platform === "win32" ? key.toUpperCase() : key + if (normalized.startsWith("LC_") || allowed.has(normalized)) result[key] = value + } + return { + ...result, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } + } + + function credentialRoots(env: Record): string[] { + const home = env.HOME || env.USERPROFILE + const roots = new Set() + const add = (value?: string) => { + if (!value) return + const resolved = path.resolve(value) + if (path.isAbsolute(resolved)) roots.add(resolved) + } + if (home) { + add(path.join(home, ".aws")) + add(path.join(home, ".azure")) + add(path.join(home, ".config", "gcloud")) + add(path.join(home, ".config", "gh")) + add(path.join(home, ".kube")) + } + for (const key of [ + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AZURE_CONFIG_DIR", + "CLOUDSDK_CONFIG", + "KUBECONFIG", + ]) { + add(env[key]) + } + return [...roots] + } + + function outsideRoots(value: string, roots: string[]): boolean { + const exact = path.resolve(value) + return !roots.some((root) => exact === root || exact.startsWith(root + path.sep)) + } + + function shell(): string { + if (process.platform === "win32") return process.env.ComSpec || process.env.COMSPEC || "cmd.exe" + return "/bin/sh" + } + + function output(stream: NodeJS.ReadableStream, limit: number, name: "stdout" | "stderr"): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let settled = false + const fail = (error: unknown) => { + if (settled) return + settled = true + reject(error) + } + stream.on("data", (value: Buffer | string) => { + if (settled) return + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + size += chunk.length + if (size > limit) { + fail(new Error(`tokenCommand ${name} exceeded ${limit} bytes`)) + return + } + chunks.push(chunk) + }) + stream.once("error", fail) + stream.once("end", () => { + if (settled) return + settled = true + resolve(Buffer.concat(chunks, size).toString("utf8")) + }) + }) + } + + async function rawStop(child: ChildProcess, detached: boolean): Promise { + await Shell.killTree(child, { + detached, + exited: () => child.exitCode !== null || child.signalCode !== null, + }) + } + + async function launch(input: RunOptions): Promise { + return AuthoritySignal.exclusive(async () => { + if (input.projectDeclared) await ProjectTrust.require(Instance.project, "provider_token_command") + return CredentialLifecycle.admit(async () => { + // The credential barrier may have awaited another server's mutation; + // trust is rechecked afterward while the authority lease is still held. + if (input.projectDeclared) await ProjectTrust.require(Instance.project, "provider_token_command") + + const env = environment() + const readable = credentialRoots(env) + const policy = await Config.trustedSandbox() + const sandbox = Sandbox.plan({ + command: input.command, + shell: shell(), + cwd: Instance.directory, + workspace: [Instance.directory, Instance.worktree], + readable, + unreadable: OpenScience.kernelSensitivePaths().filter((value) => outsideRoots(value, readable)), + options: policy, + }) + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + Sandbox.cleanup(sandbox) + throw new Error("Could not capture the Linux server identity for tokenCommand launch") + } + const wrapped = WindowsJobLauncher.wrap({ + file: sandbox.file, + args: sandbox.args ?? [], + shell: sandbox.sandboxed ? false : sandbox.useShell, + linuxOwner, + }) + let child: ChildProcess + try { + child = spawn(wrapped.file, wrapped.args, { + cwd: Instance.directory, + env, + shell: false, + detached: process.platform !== "win32", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + const id = `provider-token-${crypto.randomUUID()}` + const state = active() + try { + const registered = await CredentialProcessLedger.register({ + id, + kind: "provider", + pid: child.pid!, + detached: process.platform !== "win32", + projectID: Instance.project.id, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error("tokenCommand exited before durable process registration") + // The generic ledger owns Windows and Darwin gate release because it + // must persist their kernel ownership handles first. Linux has no Job + // handle to assign, but still uses the same pre-exec server-identity + // gate so even a one-shot `echo` cannot beat durable registration. + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid!) + } + state.ids.add(id) + return { child, completion, id, state, sandbox } + } catch (error) { + const failures: unknown[] = [] + await CredentialProcessLedger.revoke({ id }).catch((failure) => failures.push(failure)) + await rawStop(child, process.platform !== "win32").catch((failure) => failures.push(failure)) + Sandbox.cleanup(sandbox) + if (failures.length) { + throw new AggregateError([error, ...failures], "tokenCommand launch ownership cleanup failed") + } + throw error + } + }) + }) + } + + export async function run(input: RunOptions): Promise { + if (!input.command.trim()) throw new Error("tokenCommand must not be empty") + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS + const maxStdout = input.maxStdoutBytes ?? MAX_STDOUT_BYTES + const maxStderr = input.maxStderrBytes ?? MAX_STDERR_BYTES + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("tokenCommand timeout must be positive") + if (!Number.isSafeInteger(maxStdout) || maxStdout <= 0) + throw new Error("tokenCommand stdout limit must be positive") + if (!Number.isSafeInteger(maxStderr) || maxStderr <= 0) + throw new Error("tokenCommand stderr limit must be positive") + + const launched = await launch(input) + const streams = Promise.all([ + output(launched.child.stdout!, maxStdout, "stdout"), + output(launched.child.stderr!, maxStderr, "stderr"), + ]) + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`tokenCommand timed out after ${timeoutMs}ms`)), timeoutMs) + }) + let normal = false + let bodyFailure: unknown + try { + const [[stdout, stderr], settled] = await Promise.race([Promise.all([streams, launched.completion]), timeout]) + normal = true + if (settled.code !== 0) { + const status = settled.code === null ? `signal ${settled.signal ?? "unknown"}` : `exit ${settled.code}` + throw new Error(`tokenCommand ${status}: ${OpenScience.redactSecrets(stderr.trim()) || "no stderr"}`) + } + const token = stdout.trim() + if (!token) throw new Error("tokenCommand produced no output") + return token + } catch (error) { + bodyFailure = error + if (!normal) { + const failures: unknown[] = [] + await CredentialProcessLedger.revoke({ id: launched.id, kind: "provider" }).catch((failure) => + failures.push(failure), + ) + await rawStop(launched.child, process.platform !== "win32").catch((failure) => failures.push(failure)) + if (failures.length) { + throw new AggregateError([error, ...failures], "tokenCommand process cleanup failed") + } + } + throw error + } finally { + if (timer) clearTimeout(timer) + launched.state.ids.delete(launched.id) + if (normal) { + try { + const complete = await CredentialProcessLedger.complete(launched.id) + if (!complete) await CredentialProcessLedger.revoke({ id: launched.id, kind: "provider" }) + } catch (cleanupFailure) { + if (bodyFailure) { + throw new AggregateError([bodyFailure, cleanupFailure], "tokenCommand completion cleanup failed") + } + throw cleanupFailure + } + } + Sandbox.cleanup(launched.sandbox) + } + } + + export function revoke(projectID?: string): Promise { + return CredentialProcessLedger.revoke({ kind: "provider", ...(projectID ? { projectID } : {}) }) + } +} + +// Credential rotations from this or another server revoke an in-flight helper +// before any new helper is admitted against the refreshed snapshot. +CredentialLifecycle.onRevoke(async () => { + await ProviderTokenCommand.revoke() +}) diff --git a/backend/cli/src/pty/environment.ts b/backend/cli/src/pty/environment.ts index b87d42fd..d4cf241c 100644 --- a/backend/cli/src/pty/environment.ts +++ b/backend/cli/src/pty/environment.ts @@ -9,6 +9,8 @@ const inherited = new Set([ "TERM_SESSION_ID", ]) +const shellName = (command: string) => command.replace(/\\/g, "/").split("/").at(-1)?.toLowerCase() + export function terminalEnv( source: NodeJS.ProcessEnv, projectID: string, @@ -22,14 +24,17 @@ export function terminalEnv( ), ) const host = machine.split(".")[0]?.replace(/[^a-zA-Z0-9_-]/g, "") || "localhost" - const prompt: Record = command.endsWith("zsh") - ? { PROMPT: `%n@${host} %1~ %# ` } - : command.endsWith("sh") - ? { PS1: `\\u@${host} \\W \\$ ` } - : {} + const shell = shellName(command) + const prompt: Record = + shell === "zsh" + ? { PROMPT: `%n@${host} %1~ %# `, RPROMPT: "", PROMPT_EOL_MARK: "" } + : shell === "bash" || shell === "sh" || shell === "dash" || shell === "ksh" + ? { PS1: `\\u@${host} \\W \\$ ` } + : {} return { ...env, ...prompt, + ...(shell === "bash" ? { BASH_SILENCE_DEPRECATION_WARNING: "1" } : {}), TERM: "xterm-256color", HISTFILE: "/dev/null", SHELL_SESSIONS_DISABLE: "1", @@ -40,7 +45,10 @@ export function terminalEnv( } export function terminalArgs(command: string) { - if (command.endsWith("zsh")) return ["-d", "-l"] - if (command.endsWith("sh")) return ["-l"] + const shell = shellName(command) + if (shell === "zsh") return ["-d", "-f", "-i"] + if (shell === "bash") return ["--noprofile", "--norc", "-i"] + if (shell === "fish") return ["--no-config", "--interactive"] + if (shell === "sh" || shell === "dash" || shell === "ksh") return ["-i"] return [] } diff --git a/backend/cli/src/pty/index.ts b/backend/cli/src/pty/index.ts index a9656744..b0d5682c 100644 --- a/backend/cli/src/pty/index.ts +++ b/backend/cli/src/pty/index.ts @@ -9,9 +9,12 @@ import { Instance } from "../project/instance" import { lazy } from "@synsci/util/lazy" import { Shell } from "@/shell/shell" import { ExecutionAuthority } from "@/project/execution" +import { AuthoritySignal } from "@/project/authority-signal" +import { AuthorityProcessLedger } from "@/project/authority-process" import { Sandbox } from "@/sandbox/sandbox" import { OpenScience } from "@/openscience" import { terminalArgs, terminalEnv } from "./environment" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" export namespace Pty { const log = Log.create({ service: "pty" }) @@ -77,10 +80,16 @@ export namespace Pty { const state = Instance.state( () => new Map(), async (sessions) => { + const projects = new Set() + for (const session of sessions.values()) { + projects.add(session.info.projectID) + } + // Revoke durable ownership while each exact leader is still available + // for identity/group verification. Native PTY cleanup follows only as a + // local handle fallback during failed registration; registered sessions + // are already gone when revoke resolves. + await Promise.all([...projects].map((projectID) => AuthorityProcessLedger.revoke({ kind: "pty", projectID }))) for (const session of sessions.values()) { - try { - session.process.kill() - } catch {} for (const ws of session.subscribers) { ws.close() } @@ -98,82 +107,135 @@ export namespace Pty { } export async function create(input: CreateInput) { - const authority = await ExecutionAuthority.require({ - projectID: Instance.project.id, - sessionID: input.sessionID, - capability: "terminal", - }) const id = Identifier.create("pty", false) const command = Shell.preferred() const args = terminalArgs(command) - const cwd = authority.workspace - const source = await OpenScience.subprocessEnv(process.env) - const env = terminalEnv(source, Instance.project.id, input.sessionID, command) - const sandbox = Sandbox.wrapArgv({ - file: command, - args, - workspace: authority.writable, - unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, - }) - log.info("creating session", { id, cmd: command, args, cwd }) - const spawn = await pty() - const ptyProcess = spawn(sandbox.file, sandbox.args, { - name: "xterm-256color", - cwd, - env, - }) + return AuthoritySignal.exclusive(async () => { + const authority = await ExecutionAuthority.require({ + projectID: Instance.project.id, + sessionID: input.sessionID, + capability: "terminal", + }) + const cwd = authority.workspace + // Interactive PTY output is not a redaction boundary. Keep provider/cloud + // credentials on the host; terminals receive runtime discovery only. + const source = OpenScience.kernelEnv(process.env) + const env = terminalEnv(source, Instance.project.id, input.sessionID, command) + const sandbox = Sandbox.wrapArgv({ + file: command, + args, + workspace: authority.writable, + readable: authority.readable, + unreadable: OpenScience.kernelSensitivePaths(), + options: authority.sandbox, + }) + const launch = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + log.info("creating session", { id, cmd: command, args, cwd }) - const info = { - id, - title: input.title || `Terminal ${id.slice(-4)}`, - command, - args, - cwd, - projectID: Instance.project.id, - sessionID: input.sessionID, - authority, - status: "running", - pid: ptyProcess.pid, - } as const - const session: ActiveSession = { - info, - process: ptyProcess, - buffer: "", - subscribers: new Set(), - } - state().set(id, session) - ptyProcess.onData((data) => { - let open = false - for (const ws of session.subscribers) { - if (ws.readyState !== 1) { - session.subscribers.delete(ws) - continue + const ptyProcess = (() => { + try { + return spawn(launch.file, launch.args, { + name: "xterm-256color", + cwd, + env, + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error } - open = true - ws.send(data) - } - if (open) return - session.buffer += data - if (session.buffer.length <= BUFFER_LIMIT) return - session.buffer = session.buffer.slice(-BUFFER_LIMIT) - }) - ptyProcess.onExit(({ exitCode }) => { - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - for (const ws of session.subscribers) { - ws.close() + })() + + let session: ActiveSession | undefined + let earlyExit: number | undefined + let earlyBuffer = "" + ptyProcess.onData((data) => { + const active = session + if (!active) { + earlyBuffer += data + if (earlyBuffer.length > BUFFER_LIMIT) earlyBuffer = earlyBuffer.slice(-BUFFER_LIMIT) + return + } + let open = false + for (const ws of active.subscribers) { + if (ws.readyState !== 1) { + active.subscribers.delete(ws) + continue + } + open = true + ws.send(data) + } + if (open) return + active.buffer += data + if (active.buffer.length <= BUFFER_LIMIT) return + active.buffer = active.buffer.slice(-BUFFER_LIMIT) + }) + ptyProcess.onExit(({ exitCode }) => { + Sandbox.cleanup(sandbox) + if (!session) { + earlyExit = exitCode + return + } + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + for (const ws of session.subscribers) ws.close() + session.subscribers.clear() + void Bus.publish(Event.Exited, { id, exitCode }) + state().delete(id) + void AuthorityProcessLedger.complete(id).catch((error) => + log.error("failed to complete terminal authority record", { id, error }), + ) + }) + + const registered = await AuthorityProcessLedger.register({ + id, + kind: "pty", + pid: ptyProcess.pid, + projectID: Instance.project.id, + sessionID: input.sessionID, + authorityGeneration: authority.generation, + windowsRelease: launch.release, + }).catch(async (error) => { + await AuthorityProcessLedger.revoke({ id, kind: "pty" }).catch(() => undefined) + try { + ptyProcess.kill() + } catch {} + Sandbox.cleanup(sandbox) + throw error + }) + if (!registered || earlyExit !== undefined) { + await AuthorityProcessLedger.revoke({ id, kind: "pty" }) + try { + ptyProcess.kill() + } catch {} + Sandbox.cleanup(sandbox) + throw new Error( + `Terminal process exited before durable authority registration (code ${earlyExit ?? "unknown"})`, + ) } - session.subscribers.clear() - Bus.publish(Event.Exited, { id, exitCode }) - for (const ws of session.subscribers) { - ws.close() + + const info = { + id, + title: input.title || `Terminal ${id.slice(-4)}`, + command, + args, + cwd, + projectID: Instance.project.id, + sessionID: input.sessionID, + authority, + status: "running", + pid: ptyProcess.pid, + } as const + session = { + info, + process: ptyProcess, + buffer: earlyBuffer, + subscribers: new Set(), } - state().delete(id) + state().set(id, session) + void Bus.publish(Event.Created, { info }) + return info }) - Bus.publish(Event.Created, { info }) - return info } export async function update(id: string, input: UpdateInput) { @@ -193,9 +255,7 @@ export namespace Pty { const session = state().get(id) if (!session) return log.info("removing session", { id }) - try { - session.process.kill() - } catch {} + await AuthorityProcessLedger.revoke({ id, kind: "pty" }) for (const ws of session.subscribers) { ws.close() } diff --git a/backend/cli/src/sandbox/sandbox.ts b/backend/cli/src/sandbox/sandbox.ts index ab1a6abe..4de773eb 100644 --- a/backend/cli/src/sandbox/sandbox.ts +++ b/backend/cli/src/sandbox/sandbox.ts @@ -23,13 +23,9 @@ const log = Log.create({ service: "sandbox" }) * - Linux → `bubblewrap` (bwrap) mount namespaces. * - other → no backend; the caller decides whether to warn, error, or run. * - * The model is deliberately *write-containment* (allow-by-default, deny writes - * outside an allowlist, optionally deny network) rather than a deny-by-default - * syscall jail: research workflows run arbitrary compilers, package managers and - * interpreters, and a strict jail would break far more than it protects. Reads - * stay open; the threat this stops is tampering with files outside the workspace - * (`~/.ssh`, `~/.bashrc`, other projects) and, in network-deny mode, silent - * exfiltration. + * Both backends are deny-by-default and expose only system/runtime roots plus + * explicit session grants. Linux bubblewrap starts from its native empty tmpfs + * root; mounting the host root, even read-only, would defeat read isolation. */ export namespace Sandbox { export type Backend = "seatbelt" | "bubblewrap" | "none" @@ -37,6 +33,11 @@ export namespace Sandbox { export interface Policy { /** Absolute paths the sandboxed process may write to. */ writable: string[] + /** Absolute grant/runtime roots the process may read. */ + readable?: string[] + /** Exact ancestor directories that a resolver may enumerate while walking + * toward an allowed subtree. Children are not made readable. */ + readableExact?: string[] /** Exact host files the sandboxed process must not be able to read. */ unreadable?: string[] /** Whether the sandboxed process may reach the network. */ @@ -67,6 +68,8 @@ export namespace Sandbox { /** True when the command is wrapped in an OS sandbox. */ sandboxed: boolean backend: Backend + /** Unique owner-only host temp directory granted only to this process. */ + temporary?: string /** One-time human-readable note (e.g. sandbox requested but unavailable). */ warning?: string } @@ -79,6 +82,8 @@ export namespace Sandbox { args: string[] sandboxed: boolean backend: Backend + /** Unique owner-only host temp directory granted only to this process. */ + temporary?: string warning?: string } @@ -97,11 +102,10 @@ export namespace Sandbox { // --unshare-pid needs a usable PID namespace. Probe with the same namespace // ops the real sandbox uses so detection matches enforcement. try { - const res = spawnSync( - bin, - ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--", "true"], - { stdio: "ignore", timeout: 5000 }, - ) + const res = spawnSync(bin, [...bubblewrapArgs({ writable: [], network: false }), "--", "/usr/bin/true"], { + stdio: "ignore", + timeout: 5000, + }) return res.status === 0 } catch { return false @@ -134,46 +138,218 @@ export namespace Sandbox { platform: NodeJS.Platform backend: Backend available: boolean + readIsolation: "grant_only" | "unavailable" + networkIsolation: "deny_all" | "unavailable" tool?: string reason?: string } { const b = backend() - if (b === "seatbelt") return { platform: process.platform, backend: b, available: true, tool: "sandbox-exec" } - if (b === "bubblewrap") return { platform: process.platform, backend: b, available: true, tool: "bwrap" } + if (b === "seatbelt") { + return { + platform: process.platform, + backend: b, + available: true, + readIsolation: "grant_only", + networkIsolation: "deny_all", + tool: "sandbox-exec", + } + } + if (b === "bubblewrap") { + return { + platform: process.platform, + backend: b, + available: true, + readIsolation: "grant_only", + networkIsolation: "deny_all", + tool: "bwrap", + } + } const reason = process.platform === "darwin" ? "sandbox-exec not found on PATH" : process.platform === "linux" ? "bubblewrap (bwrap) is not installed, or unprivileged user namespaces are disabled" : `no sandbox backend for platform "${process.platform}"` - return { platform: process.platform, backend: "none", available: false, reason } + return { + platform: process.platform, + backend: "none", + available: false, + readIsolation: "unavailable", + networkIsolation: "unavailable", + reason, + } } // ── writable-path assembly ────────────────────────────────────────────────── - /** Temp dirs a sandboxed command legitimately needs to write to. */ - export function tempDirs(): string[] { - const dirs = new Set() - const add = (d?: string | null) => { - if (d) dirs.add(d) + const temporaryRoots = new Set() + + /** Allocate a temp root for one spawned sandbox. Sharing one per server lets + * mutually untrusted projects/sessions read and overwrite each other's temp + * files, even when the main workspace grants are disjoint. */ + function privateTemp(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `openscience-sandbox-${process.pid}-`)) + fs.chmodSync(directory, 0o700) + const canonical = fs.realpathSync.native(directory) + temporaryRoots.add(canonical) + return canonical + } + + /** Release the per-spawn temp root. Only roots allocated by this module can + * be removed, so a forged Plan cannot turn this helper into a deletion API. */ + export function cleanup(input: Pick | Pick): void { + const temporary = input.temporary + if (!temporary || !temporaryRoots.delete(temporary)) return + try { + fs.rmSync(temporary, { recursive: true, force: true }) + } catch (error) { + // A killed/malicious child may leave restrictive modes behind. The + // unique 0700 root remains isolated even if best-effort reclamation + // cannot remove it immediately. + log.warn("failed to clean sandbox temp directory", { temporary, error }) + } + } + + process.once("exit", () => { + for (const temporary of [...temporaryRoots]) cleanup({ temporary }) + }) + + function withTempEnvironment(argv: string[], temporary: string) { + return ["/usr/bin/env", `TMPDIR=${temporary}`, `TMP=${temporary}`, `TEMP=${temporary}`, ...argv] + } + + /** Canonicalize an existing path or a nonexistent tail below its nearest + * existing ancestor. Relative paths and broken symlink ancestors are + * ambiguous policy inputs and are dropped fail-closed. */ + function canonicalPolicyPath(input: string): string | undefined { + if (!path.isAbsolute(input)) { + log.warn("refusing a relative sandbox path", { path: input }) + return + } + let cursor = path.normalize(input) + const tail: string[] = [] + while (true) { + try { + fs.lstatSync(cursor) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + log.warn("refusing an unreadable sandbox path", { path: input, error }) + return + } + const parent = path.dirname(cursor) + if (parent === cursor) return + tail.unshift(path.basename(cursor)) + cursor = parent + } + } + try { + const real = fs.realpathSync.native(cursor) + return path.join(real, ...tail) + } catch (error) { + log.warn("refusing an ambiguous sandbox path", { path: input, error }) + return } - add(process.env.TMPDIR) - add(process.env.TMP) - add(process.env.TEMP) - add(os.tmpdir()) - add("/tmp") - if (process.platform === "darwin") add("/private/tmp") - return [...dirs] } function dedupe(paths: string[]): string[] { const out = new Set() - for (const p of paths) { - if (p) out.add(path.resolve(p)) - } + for (const p of paths) + if (p) { + const canonical = canonicalPolicyPath(p) + if (canonical) out.add(canonical) + } return [...out] } + function traversalRoots(paths: string[]): string[] { + const result = new Set() + for (const value of dedupe(paths)) { + let cursor = path.dirname(value) + while (true) { + result.add(cursor) + const parent = path.dirname(cursor) + if (parent === cursor) break + cursor = parent + } + } + return [...result] + } + + /** Read-only roots needed to launch common local research runtimes. These + * are installation/code roots, never the user's home directory as a whole. */ + function runtimeReadRoots(entrypoints: string[]): string[] { + const roots = new Set() + const add = (value?: string | null) => { + if (!value || !path.isAbsolute(value)) return + const home = os.homedir() + if ( + value === path.parse(value).root || + value === home || + home.startsWith(value + path.sep) || + ["/etc", "/var", "/tmp", "/home", "/root", "/opt"].includes(value) + ) { + return + } + roots.add(value) + } + const installation = (value: string) => { + const versioned = [ + { marker: "/.pyenv/versions/", depth: 1 }, + { marker: "/.asdf/installs/", depth: 2 }, + { marker: "/.local/share/uv/python/", depth: 1 }, + { marker: "/miniconda3/envs/", depth: 1 }, + { marker: "/.nvm/versions/node/", depth: 1 }, + ].find((item) => value.includes(item.marker)) + if (versioned) { + const start = value.indexOf(versioned.marker) + versioned.marker.length + const parts = value.slice(start).split(path.sep).slice(0, versioned.depth) + add(value.slice(0, start) + parts.join(path.sep)) + return + } + for (const marker of ["/.bun/", "/.pyenv/", "/.asdf/", "/.volta/"]) { + const index = value.indexOf(marker) + if (index >= 0) { + add(value.slice(0, index + marker.length - 1)) + return + } + } + for (const root of ["/opt/conda", "/opt/rocm", "/opt/cuda", "/opt/nvidia"]) { + if (value === root || value.startsWith(root + path.sep)) { + add(root) + return + } + } + } + for (const value of (process.env.PATH ?? "").split(path.delimiter)) { + add(value) + if (path.isAbsolute(value)) installation(value) + } + for (const value of [ + "/opt/homebrew", + "/usr/local", + "/Library/Developer/CommandLineTools", + "/Library/Frameworks", + "/private/etc/ssl", + ]) { + if (fs.existsSync(value)) add(value) + } + for (const entrypoint of entrypoints) { + const located = path.isAbsolute(entrypoint) ? entrypoint : Bun.which(entrypoint) + if (!located) continue + add(path.dirname(located)) + try { + const real = fs.realpathSync.native(located) + add(path.dirname(real)) + installation(real) + } catch { + // A missing/broken entrypoint is not made readable. Spawn will fail + // normally rather than widening the policy around an ambiguous path. + } + } + return [...roots] + } + /** * A path too broad to ever be a sandbox writable root: granting write here * would hand back most of the filesystem and defeat containment. Guards @@ -205,16 +381,28 @@ export namespace Sandbox { return roots.includes(p) } + /** Canonicalize one user-configured writable root. Invalid, ambiguous, or + * over-broad roots are rejected by settings/CLI callers before persistence; + * buildPolicy repeats the same check so hand-edited config still fails closed. */ + export function writableGrant(input: string): string | undefined { + const canonical = canonicalPolicyPath(input) + if (!canonical || tooBroadToConfine(canonical)) return + return canonical + } + /** Assemble the writable allowlist for a policy, dropping over-broad roots. */ function buildPolicy(input: { workspace: string[] + temporary: string + readable?: string[] extraWritable?: string[] unreadable?: string[] + entrypoints?: string[] options: Options }): Policy { const candidates = dedupe([ ...input.workspace, - ...tempDirs(), + input.temporary, ...(input.options.allowWrite ?? []), ...(input.extraWritable ?? []), ]) @@ -225,8 +413,17 @@ export namespace Sandbox { } return true }) + const readable = dedupe([ + ...runtimeReadRoots(input.entrypoints ?? []), + ...input.workspace, + ...(input.readable ?? []), + ...(input.extraWritable ?? []), + ...writable, + ]).filter((value) => !tooBroadToConfine(value)) return { writable, + readable, + readableExact: traversalRoots(readable), unreadable: dedupe(input.unreadable ?? []).filter((value) => !tooBroadToConfine(value)), network: (input.options.network ?? "allow") !== "deny", } @@ -251,14 +448,46 @@ export namespace Sandbox { } export function seatbeltProfile(policy: Policy): string { - const lines = ["(version 1)", "(allow default)"] - if (!policy.network) lines.push("(deny network*)") + const lines = [ + "(version 1)", + "(deny default)", + '(import "system.sb")', + "(allow process-fork)", + "(allow process-exec)", + "(allow signal (target self) (target children))", + "(allow process-info* (target self))", + "(allow file-read-metadata file-test-existence)", + ] + // SBPL's `remote ip` filter accepts only `*` and `localhost`, not literal + // addresses or CIDR ranges. An allow-with-private-denies profile would + // therefore expose LAN, link-local, and cloud-metadata endpoints. Keep the + // default deny in force for every socket operation in both policy modes. + const readable = withPrivateAliases(dedupe(policy.readable ?? [])) + if (readable.length) { + lines.push( + `(allow file-read* file-test-existence ${readable.map((value) => `(subpath "${sbpl(value)}")`).join(" ")})`, + ) + } + const readableExact = withPrivateAliases(dedupe(policy.readableExact ?? [])) + if (readableExact.length) { + lines.push( + `(allow file-read* file-test-existence ${readableExact.map((value) => `(literal "${sbpl(value)}")`).join(" ")})`, + ) + } const unreadable = withPrivateAliases(dedupe(policy.unreadable ?? [])) if (unreadable.length) { - lines.push(`(deny file-read* ${unreadable.map((value) => `(literal "${sbpl(value)}")`).join(" ")})`) + lines.push( + `(deny file-read* ${unreadable + .map((value) => { + try { + return fs.statSync(value).isDirectory() ? `(subpath "${sbpl(value)}")` : `(literal "${sbpl(value)}")` + } catch { + return `(literal "${sbpl(value)}")` + } + }) + .join(" ")})`, + ) } - lines.push("(deny file-write*)") - const writable = withPrivateAliases(dedupe(policy.writable)) if (writable.length) { lines.push(`(allow file-write* ${writable.map((p) => `(subpath "${sbpl(p)}")`).join(" ")})`) @@ -270,15 +499,69 @@ export namespace Sandbox { // ── Linux: bubblewrap (bwrap) ─────────────────────────────────────────────── + /** Host-controlled Linux roots required to start normal dynamically-linked + * research tools. User data roots (/home, /root, /var) are intentionally not + * included: projects, installations, and other data enter only through the + * explicit readable/writable policy below. */ + function linuxRuntimeMounts(): string[] { + return [ + "/usr", + "/nix", + "/etc/ld.so.cache", + "/etc/ld.so.conf", + "/etc/ld.so.conf.d", + "/etc/alternatives", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/hosts", + "/etc/resolv.conf", + "/etc/gai.conf", + "/etc/host.conf", + "/etc/protocols", + "/etc/services", + "/etc/localtime", + "/etc/timezone", + "/etc/ssl/certs", + "/etc/ssl/cert.pem", + "/etc/ssl/openssl.cnf", + "/etc/pki/tls/certs", + "/etc/pki/ca-trust", + "/etc/ca-certificates", + "/etc/fonts", + ].filter((value) => fs.existsSync(value)) + } + + /** Preserve conventional merged-/usr aliases without exposing anything + * beyond the corresponding host runtime directory. */ + function linuxRuntimeAliases(): string[] { + const args: string[] = [] + for (const value of ["/bin", "/sbin", "/lib", "/lib32", "/lib64"]) { + if (!fs.existsSync(value)) continue + const stat = fs.lstatSync(value) + if (stat.isSymbolicLink()) { + args.push("--symlink", fs.readlinkSync(value), value) + continue + } + args.push("--ro-bind", value, value) + } + return args + } + export function bubblewrapArgs(policy: Policy): string[] { - // Whole fs read-only, a fresh /dev and /proc, and a throwaway writable /tmp; - // then re-mount the bits that must be writable on top. - const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"] + // Bubblewrap creates an empty tmpfs root. Populate only runtime and policy + // grants; never bind the host root, even read-only, because doing so exposes + // every same-user secret to arbitrary project code. + const args = ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"] + for (const value of linuxRuntimeMounts()) args.push("--ro-bind", value, value) + args.push(...linuxRuntimeAliases()) + for (const value of dedupe(policy.readable ?? [])) args.push("--ro-bind-try", value, value) + const tmpRoots = new Set(dedupe(["/tmp"])) for (const p of dedupe(policy.writable)) { // Skip only the /tmp mount root itself — it is provided as a fresh tmpfs and // re-binding host /tmp would defeat it. A workspace that lives *under* /tmp // still needs binding on top of the tmpfs, or its writes vanish. - if (p === "/tmp") continue + if (tmpRoots.has(p)) continue // --bind-try: don't abort if the source path doesn't exist. args.push("--bind-try", p, p) } @@ -289,12 +572,24 @@ export namespace Sandbox { // credential cannot be read and the sandbox cannot create it, so only // mount masks for files that exist when the namespace is assembled. if (!fs.existsSync(value)) continue - args.push("--ro-bind-try", "/dev/null", value) + if (fs.statSync(value).isDirectory()) args.push("--tmpfs", value) + else args.push("--ro-bind-try", "/dev/null", value) } - if (!policy.network) args.push("--unshare-net") - // --unshare-pid: don't share the host PID namespace, so /proc//root of a - // same-uid host process can't be used to write through the read-only bind. - args.push("--unshare-pid", "--die-with-parent") + // bubblewrap cannot express "internet but never host loopback" without a + // separately configured network namespace. Sharing the host namespace in + // allow mode would expose 127.0.0.1 services, so fail closed and deny all + // sockets on this backend in both modes. Host-brokered connectors enforce + // the curated domain policy outside arbitrary project processes. + args.push("--unshare-net") + // The PID namespace's bwrap-owned PID 1 remains alive until every descendant + // exits. A setsid()+double-fork daemon is reparented to that PID 1 rather than + // host init, and --die-with-parent kills the namespace if the wrapper/server + // disappears. This is the kernel-backed lifecycle boundary process groups + // alone cannot provide. + // Detach from any controlling terminal inherited from a shared PTY. This + // closes TIOCSTI-style input injection back into the host session; older + // bubblewrap builds without this flag fail the backend probe closed. + args.push("--unshare-pid", "--die-with-parent", "--new-session") return args } @@ -352,16 +647,33 @@ export namespace Sandbox { cwd: string /** Workspace roots (Instance.directory + worktree) that stay writable. */ workspace: string[] + /** Additional explicit read-only grant roots for this process. */ + readable?: string[] + /** Exact host credential files to mask from the process. */ + unreadable?: string[] options?: Options }): Plan { const { backend: b, warning } = decide(input.options) if (b === "none") { return { file: input.command, useShell: input.shell, sandboxed: false, backend: "none", warning } } - const policy = buildPolicy({ workspace: input.workspace, options: input.options! }) - const s = specForArgv([input.shell, "-c", input.command], policy)! - log.info("sandboxing command", { backend: b, network: policy.network, writable: policy.writable.length }) - return { file: s.file, args: s.args, useShell: false, sandboxed: true, backend: b, warning } + const temporary = privateTemp() + try { + const policy = buildPolicy({ + workspace: input.workspace, + temporary, + readable: input.readable, + unreadable: input.unreadable, + entrypoints: [input.shell], + options: input.options!, + }) + const s = specForArgv(withTempEnvironment([input.shell, "-c", input.command], temporary), policy)! + log.info("sandboxing command", { backend: b, network: policy.network, writable: policy.writable.length }) + return { file: s.file, args: s.args, useShell: false, sandboxed: true, backend: b, temporary, warning } + } catch (error) { + cleanup({ temporary }) + throw error + } } /** @@ -375,6 +687,8 @@ export namespace Sandbox { args: string[] /** Workspace roots that stay writable. */ workspace: string[] + /** Explicit read-only grant roots for this process. */ + readable?: string[] /** Extra paths (e.g. a generated kernel script under /tmp) to keep writable/visible. */ extraWritable?: string[] /** Exact host credential files to mask from the process. */ @@ -385,15 +699,24 @@ export namespace Sandbox { if (b === "none") { return { file: input.file, args: input.args, sandboxed: false, backend: "none", warning } } - const policy = buildPolicy({ - workspace: input.workspace, - extraWritable: input.extraWritable, - unreadable: input.unreadable, - options: input.options!, - }) - const s = specForArgv([input.file, ...input.args], policy)! - log.info("sandboxing process", { backend: b, network: policy.network, writable: policy.writable.length }) - return { file: s.file, args: s.args, sandboxed: true, backend: b, warning } + const temporary = privateTemp() + try { + const policy = buildPolicy({ + workspace: input.workspace, + temporary, + readable: input.readable, + extraWritable: input.extraWritable, + unreadable: input.unreadable, + entrypoints: [input.file], + options: input.options!, + }) + const s = specForArgv(withTempEnvironment([input.file, ...input.args], temporary), policy)! + log.info("sandboxing process", { backend: b, network: policy.network, writable: policy.writable.length }) + return { file: s.file, args: s.args, sandboxed: true, backend: b, temporary, warning } + } catch (error) { + cleanup({ temporary }) + throw error + } } // ── self-test (proves the boundary actually holds on this machine) ────────── @@ -450,11 +773,16 @@ export namespace Sandbox { const shell = Shell.acceptable() const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-sbx-")) const outside = path.join(os.homedir(), `.openscience-sbx-escape-${process.pid}`) + const outsideRead = path.join(os.tmpdir(), `.openscience-sbx-sibling-${process.pid}`) const checks: Check[] = [] - const run = (command: string, network: "allow" | "deny") => { + const run = async (command: string, network: "allow" | "deny") => { const p = plan({ command, shell, cwd: work, workspace: [work], options: { enabled: true, network } }) - return runAsync(p.file, p.args ?? [], work) + try { + return await runAsync(p.file, p.args ?? [], work) + } finally { + cleanup(p) + } } try { @@ -478,6 +806,14 @@ export namespace Sandbox { return { backend: b, available: true, checks, ok: false } } + fs.writeFileSync(outsideRead, "sibling-secret", { mode: 0o600 }) + const ungrantedRead = await run(`cat "${outsideRead}"`, "deny") + checks.push({ + name: "read outside explicit grants is blocked", + pass: ungrantedRead.status !== 0, + detail: ungrantedRead.status === 0 ? `read succeeded for ungranted ${outsideRead}` : undefined, + }) + fs.rmSync(outside, { force: true }) const escape = await run(`printf x > "${outside}"`, "allow") const escaped = fs.existsSync(outside) @@ -494,29 +830,23 @@ export namespace Sandbox { : undefined, }) - const curlCmd = `curl -m 5 -s -o /dev/null https://example.com` if (Bun.which("curl")) { - // Distinguish "sandbox blocked it" from "machine is offline" by checking - // that egress works in allow-mode before asserting deny-mode blocks it. - const allow = await run(curlCmd, "allow") - if (allow.status !== 0) { - checks.push({ - name: "network egress blocked in deny mode", - pass: true, - skipped: true, - detail: "no outbound connectivity — inconclusive", - }) - } else { - const deny = await run(curlCmd, "deny") + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("sandbox probe") }) + try { + const target = `http://127.0.0.1:${server.port}` + const control = await fetch(target).then((response) => response.text()) + const denied = await run(`curl -m 2 -s -o /dev/null ${target}`, "allow") checks.push({ - name: "network egress blocked in deny mode", - pass: deny.status !== 0, - detail: deny.status === 0 ? "egress succeeded despite deny" : undefined, + name: "network sockets are denied by the backend", + pass: control === "sandbox probe" && denied.status !== 0, + detail: denied.status === 0 ? "loopback access succeeded despite backend deny-all" : undefined, }) + } finally { + server.stop(true) } } else { checks.push({ - name: "network egress blocked in deny mode", + name: "network sockets are denied by the backend", pass: true, skipped: true, detail: "curl not available — skipped", @@ -526,6 +856,9 @@ export namespace Sandbox { try { fs.rmSync(outside, { force: true }) } catch {} + try { + fs.rmSync(outsideRead, { force: true }) + } catch {} try { fs.rmSync(work, { recursive: true, force: true }) } catch {} diff --git a/backend/cli/src/science/command/registry.ts b/backend/cli/src/science/command/registry.ts index 907f96f2..bde26c51 100644 --- a/backend/cli/src/science/command/registry.ts +++ b/backend/cli/src/science/command/registry.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from "node:child_process" import z from "zod" +import { CredentialProcessLedger } from "../../credentials/process-ledger" export const CommandStatus = z.object({ id: z.string(), @@ -30,10 +31,11 @@ type Entry = CommandStatus & { const entries = new Map() export namespace CommandRuntime { - export function start( + export async function start( input: Omit, process: ChildProcess, stop: () => Promise, + options: { authorityGeneration?: string; windowsRelease?: string } = {}, ) { if (!process.pid) throw new Error("Shell command started without a process id") const value: Entry = { @@ -45,12 +47,43 @@ export namespace CommandRuntime { process, stop, } + let completed = false + const complete = () => { + completed = true + entries.delete(value.id) + // Never discard durable ownership merely because the group leader + // exited. `complete` authenticates and reaps any same-PGID background + // descendants before it removes the ledger entry; on failure the entry + // remains available to trust/session/credential revocation. + void CredentialProcessLedger.complete(value.id).catch(() => undefined) + } + process.once("exit", complete) + process.once("error", complete) + const registered = await CredentialProcessLedger.register({ + id: value.id, + kind: "command", + pid: value.process_id, + detached: globalThis.process.platform !== "win32", + projectID: value.projectID, + sessionID: value.sessionID, + authorityGeneration: options.authorityGeneration, + windowsRelease: options.windowsRelease, + }) + if (!registered) { + await stop() + throw new Error("Command exited before durable process-group ownership could be established") + } + if (completed) { + await CredentialProcessLedger.complete(value.id) + return value + } entries.set(value.id, value) return value } export function finish(id: string) { entries.delete(id) + void CredentialProcessLedger.complete(id).catch(() => undefined) } export function list(projectID: string, sessionID?: string): CommandStatus[] { @@ -69,7 +102,97 @@ export namespace CommandRuntime { export async function stop(id: string, projectID: string, sessionID: string) { const value = owned(id, projectID, sessionID) if (!value) return false - await value.stop() + let stopped = false + const stop = async () => { + if (stopped) return + stopped = true + await value.stop() + } + await CredentialProcessLedger.revoke( + { id: value.id, kind: "command", projectID, sessionID }, + { + onPinned: async (entryID) => { + if (entryID === value.id) await stop() + }, + }, + ) + await stopEntry(value, stop) return true } + + async function stopEntry(value: Entry, stop: () => Promise = value.stop): Promise { + await stop() + if (value.process.exitCode !== null || value.process.signalCode !== null) return + await new Promise((resolve, reject) => { + const done = () => { + clearTimeout(timer) + value.process.off("exit", done) + value.process.off("error", failed) + resolve() + } + const failed = (error: Error) => { + clearTimeout(timer) + value.process.off("exit", done) + value.process.off("error", failed) + reject(error) + } + const timer = setTimeout(() => { + value.process.off("exit", done) + value.process.off("error", failed) + reject(new Error(`Command ${value.id} did not exit after revocation`)) + }, 2_000) + timer.unref() + value.process.once("exit", done) + value.process.once("error", failed) + }) + } + + async function stopMatching( + scope: CredentialProcessLedger.Scope, + matches: (value: Entry) => boolean, + ): Promise { + const targets = [...entries.values()].filter(matches) + // Durable teardown must enumerate the leader's live descendant closure + // before a competing best-effort stop can kill the leader and reparent a + // setsid child outside that closure. + const targetsByID = new Map(targets.map((value) => [value.id, value])) + const stopped = new Set() + const stop = async (value: Entry) => { + if (stopped.has(value.id)) return + stopped.add(value.id) + await value.stop() + } + const recovered = await CredentialProcessLedger.revoke( + { kind: "command", ...scope }, + { + onPinned: async (id) => { + const value = targetsByID.get(id) + if (value) await stop(value) + }, + }, + ) + const results = await Promise.allSettled(targets.map((value) => stopEntry(value, () => stop(value)))) + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) + if (failures.length) throw new AggregateError(failures, "Commands could not be revoked") + return Math.max(recovered, targets.length) + } + + export function stopSession(projectID: string, sessionID: string) { + return stopMatching( + { projectID, sessionID }, + (value) => value.projectID === projectID && value.sessionID === sessionID, + ) + } + + export function stopProject(projectID: string) { + return stopMatching({ projectID }, (value) => value.projectID === projectID) + } + + /** Stop every live Bash command before a credential mutation is acknowledged. + * Unlike project/session cleanup, this is fail-closed: a stop callback that + * rejects or a child that remains alive after SIGKILL blocks reconciliation + * so the process cannot continue with a stale inherited environment. */ + export async function stopAll(): Promise { + return stopMatching({}, () => true) + } } diff --git a/backend/cli/src/science/connectors/http.ts b/backend/cli/src/science/connectors/http.ts index d9e0a158..ee1a5f29 100644 --- a/backend/cli/src/science/connectors/http.ts +++ b/backend/cli/src/science/connectors/http.ts @@ -14,6 +14,7 @@ import type { RateLimit } from "./types" import { Network } from "@/settings/network" +import { AsyncLocalStorage } from "node:async_hooks" const USER_AGENT = "openscience-science/1.0 (+https://syntheticsciences.ai)" const DEFAULT_TIMEOUT = 30_000 @@ -37,6 +38,9 @@ export interface HttpOptions extends Omit { * Empty bodies are never cached regardless. */ looksValid?: (body: string) => boolean + /** Deterministic resolver seam for connector unit tests. Production + * connectors omit this and use the operating-system DNS resolver. */ + resolveAddresses?: Network.FetchPolicy["resolveAddresses"] } interface CacheEntry { @@ -90,9 +94,39 @@ function combineSignals(a: AbortSignal, b?: AbortSignal): AbortSignal { // cap bounds in-flight requests to that host. Keyed by host so unrelated // sources are never over-serialized. -const hostPace = new Map>() -const hostActive = new Map() -const hostWaiters = new Map void>>() +interface ThrottleState { + pace: Map> + active: Map + waiters: Map void>> +} + +function throttleState(): ThrottleState { + return testContext.getStore()?.throttle ?? productionThrottle +} + +function newThrottleState(): ThrottleState { + return { pace: new Map(), active: new Map(), waiters: new Map() } +} + +const productionThrottle = newThrottleState() + +/** Request-local transport seam for deterministic connector integration tests. + * AsyncLocalStorage keeps concurrent Bun test files from racing through the + * process-global fetch function; production requests never enter this scope. */ +export interface HttpTestPolicy { + resolveAddresses: NonNullable + transport: NonNullable +} + +interface HttpTestContext extends HttpTestPolicy { + throttle: ThrottleState +} + +const testContext = new AsyncLocalStorage() + +export function withHttpTestPolicy(policy: HttpTestPolicy, action: () => T): T { + return testContext.run({ ...policy, throttle: newThrottleState() }, action) +} function hostOf(url: string): string | undefined { try { @@ -108,8 +142,9 @@ function hostOf(url: string): string | undefined { * `minIntervalMs` after the previous request began. */ function pace(host: string, minIntervalMs: number): Promise { - const ready = hostPace.get(host) ?? Promise.resolve() - hostPace.set( + const state = throttleState() + const ready = state.pace.get(host) ?? Promise.resolve() + state.pace.set( host, ready.then(() => sleep(minIntervalMs)), ) @@ -118,24 +153,26 @@ function pace(host: string, minIntervalMs: number): Promise { /** Take an in-flight slot for this host, waiting if `maxConcurrent` is reached. */ function acquire(host: string, maxConcurrent: number): Promise { - const active = hostActive.get(host) ?? 0 + const state = throttleState() + const active = state.active.get(host) ?? 0 if (active < maxConcurrent) { - hostActive.set(host, active + 1) + state.active.set(host, active + 1) return Promise.resolve() } return new Promise((resolve) => { - const queue = hostWaiters.get(host) ?? [] + const queue = state.waiters.get(host) ?? [] queue.push(resolve) - hostWaiters.set(host, queue) + state.waiters.set(host, queue) }) } /** Release an in-flight slot, handing it straight to the next waiter if any. */ function release(host: string): void { - const next = hostWaiters.get(host)?.shift() + const state = throttleState() + const next = state.waiters.get(host)?.shift() if (next) return next() - const active = hostActive.get(host) ?? 1 - hostActive.set(host, Math.max(0, active - 1)) + const active = state.active.get(host) ?? 1 + state.active.set(host, Math.max(0, active - 1)) } /** Apply the optional per-host throttle; returns a `release` to call when done. */ @@ -176,6 +213,7 @@ export async function request(url: string, opts: HttpOptions = {}) { Accept: "*/*", ...(opts.headers as Record | undefined), } + const { resolveAddresses, ...fetchOptions } = opts const done = await throttle(url, opts.rateLimit) try { @@ -185,7 +223,15 @@ export async function request(url: string, opts: HttpOptions = {}) { const timer = setTimeout(() => controller.abort(), timeout) const signal = combineSignals(controller.signal, opts.signal) try { - const res = await fetch(url, { ...opts, method, headers, signal }) + const scopedPolicy = testContext.getStore() + const res = await Network.fetch( + url, + { ...fetchOptions, method, headers, signal }, + { + resolveAddresses: resolveAddresses ?? scopedPolicy?.resolveAddresses, + transport: scopedPolicy?.transport, + }, + ) const body = await res.text() if (!res.ok && isRetryable(res.status) && attempt < retries) { const backoff = backoffMs(res, attempt) @@ -290,7 +336,8 @@ export function clearCache(): void { /** Reset per-host rate-limit pacing + concurrency state (test/debug helper). */ export function resetRateLimits(): void { - hostPace.clear() - hostActive.clear() - hostWaiters.clear() + const state = throttleState() + state.pace.clear() + state.active.clear() + state.waiters.clear() } diff --git a/backend/cli/src/science/kernel/interpreter.ts b/backend/cli/src/science/kernel/interpreter.ts new file mode 100644 index 00000000..0434b102 --- /dev/null +++ b/backend/cli/src/science/kernel/interpreter.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises" +import { constants } from "node:fs" +import path from "node:path" +import z from "zod" +import type { KernelStartOptions } from "./types" + +export const KernelEnvironmentName = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "Use a simple environment name without path separators") + +export class KernelEnvironmentUnavailable extends Error { + constructor( + readonly environmentName: string, + readonly candidates: string[], + ) { + super( + `Python environment '${environmentName}' was not found. Expected an interpreter at ${candidates.join(" or ")}.`, + ) + this.name = "KernelEnvironmentUnavailable" + } +} + +const layout = (root: string) => + process.platform === "win32" + ? { binary: path.join(root, "Scripts", "python.exe"), bin: path.join(root, "Scripts") } + : { binary: path.join(root, "bin", "python"), bin: path.join(root, "bin") } + +async function executable(file: string) { + const stat = await fs.stat(file).catch(() => undefined) + if (!stat?.isFile()) return false + return fs.access(file, process.platform === "win32" ? constants.F_OK : constants.X_OK).then( + () => true, + () => false, + ) +} + +/** + * Resolve a named project Python environment without accepting arbitrary paths. + * + * Named environments live under `.venv/`. The conventional `.venv` + * layout remains a fallback for the default `python` environment so existing + * projects use their dependencies without configuration. + */ +export async function pythonEnvironment(projectRoot: string, input?: string): Promise { + const environmentName = KernelEnvironmentName.parse(input ?? "python") + const roots = [path.join(projectRoot, ".venv", environmentName)] + if (environmentName === "python") roots.push(path.join(projectRoot, ".venv")) + const candidates = roots.map(layout) + + for (const candidate of candidates) { + if (!(await executable(candidate.binary))) continue + return { + binary: candidate.binary, + environmentName, + env: { + VIRTUAL_ENV: path.dirname(candidate.bin), + PATH: [candidate.bin, process.env.PATH].filter(Boolean).join(path.delimiter), + }, + } + } + + if (environmentName !== "python") { + throw new KernelEnvironmentUnavailable( + environmentName, + candidates.map((candidate) => candidate.binary), + ) + } + return { environmentName } +} diff --git a/backend/cli/src/science/kernel/process.ts b/backend/cli/src/science/kernel/process.ts index 401ddb00..c1942644 100644 --- a/backend/cli/src/science/kernel/process.ts +++ b/backend/cli/src/science/kernel/process.ts @@ -1,11 +1,25 @@ +import crypto from "node:crypto" import fs from "node:fs" import type { ChildProcess } from "node:child_process" +import { dlopen, FFIType, ptr } from "bun:ffi" +import { WindowsJob } from "@/process/windows-job" +import { AuthorityProcessLedger } from "@/project/authority-process" import type { KernelProcess } from "./types" const hooks = new Set<() => void>() let hooked = false -function token(pid: number) { +const procInfo = { + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, +} as const + +const openDarwinLibrary = () => dlopen("/usr/lib/libproc.dylib", procInfo) +let darwinLibrary: ReturnType | undefined + +function rawToken(pid: number) { if (process.platform === "linux") { try { const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8") @@ -16,16 +30,42 @@ function token(pid: number) { return } } + if (process.platform === "win32") return WindowsJob.identity(pid) if (process.platform !== "darwin") return - const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], { - stdout: "pipe", - stderr: "ignore", - }) - const start = result.success ? result.stdout.toString().trim() : "" - return start ? `darwin:${start}` : undefined + // PROC_PIDTBSDINFO exposes the kernel's microsecond-resolution process start + // time. `ps -o lstart` only has whole-second resolution, so two successive + // occupants of a rapidly reused PID could otherwise share the same token. + darwinLibrary ??= openDarwinLibrary() + const info = Buffer.alloc(136) + const size = darwinLibrary.symbols.proc_pidinfo(pid, 3, 0n, ptr(info), info.length) + if (size !== info.length || info.readUInt32LE(12) !== pid) return + return `darwin:${info.readBigUInt64LE(120)}:${info.readBigUInt64LE(128)}` +} + +function token(pid: number) { + const raw = rawToken(pid) + return raw ? crypto.createHash("sha256").update(raw).digest("hex") : undefined +} + +function matchesToken(pid: number, expected: string) { + const raw = rawToken(pid) + if (!raw) return false + const exact = crypto.createHash("sha256").update(raw).digest("hex") + // Linux's old token was the raw boot-clock start tick, which is already an + // exact process incarnation. Preserve safe recovery for those records while + // refusing the former second-resolution Darwin token. + return expected === exact || (process.platform === "linux" && expected === raw) } export namespace KernelProcessIdentity { + export interface Ownership { + id: string + projectID: string + sessionID: string + authorityGeneration: string + windowsRelease?: string + } + export function onExit(fn: () => void) { hooks.add(fn) if (hooked) return @@ -46,6 +86,43 @@ export namespace KernelProcessIdentity { } } + /** Register a newly spawned kernel before its ready handshake. Persisting the + * returned ownership ID lets a different OpenScience server reap surviving + * process-group children even after the recorded leader has exited. */ + export async function register(proc: ChildProcess, ownership?: Ownership): Promise { + const identity = capture(proc) + if (!identity || !ownership) return identity + if (!identity.token) { + throw new Error(`Could not establish a safe process identity for kernel child ${identity.pid}`) + } + const registered = await AuthorityProcessLedger.register({ + ...ownership, + kind: "kernel", + pid: identity.pid, + expectedIdentity: identity.token, + }) + if (!registered) return + return { ...identity, ownershipID: ownership.id } + } + + /** Enforce durable registration for KernelManager implementations that do + * not perform the standard immediate post-spawn registration themselves. */ + export async function ensureRegistered(identity: KernelProcess | undefined, ownership: Ownership) { + if (!identity || identity.ownershipID === ownership.id) return identity + if (!identity.token || !matchesRecorded(identity)) { + throw new Error("Kernel process exited or changed identity before durable registration") + } + const registered = await AuthorityProcessLedger.register({ + ...ownership, + kind: "kernel", + pid: identity.pid, + expectedIdentity: identity.token, + }) + if (!registered) throw new Error("Kernel process exited before durable registration") + identity.ownershipID = ownership.id + return identity + } + export function matches(proc: ChildProcess, identity?: KernelProcess) { if (!identity || proc.pid !== identity.pid || proc.exitCode !== null) return false try { @@ -54,6 +131,48 @@ export namespace KernelProcessIdentity { return false } if (!identity.token) return true - return token(identity.pid) === identity.token + return matchesToken(identity.pid, identity.token) + } + + export function matchesRecorded(identity?: KernelProcess) { + if (!identity) return false + try { + process.kill(identity.pid, 0) + } catch { + return false + } + if (!identity.token) return false + return matchesToken(identity.pid, identity.token) + } + + export async function terminate(identity?: KernelProcess) { + if (!identity) return false + if (identity.ownershipID) { + // An ownership ID is only returned after the durable record is synced. + // If another revoker already removed that record, its removal itself is + // proof that the exact group was successfully torn down. + await AuthorityProcessLedger.revoke({ id: identity.ownershipID, kind: "kernel" }) + if (!matchesRecorded(identity)) return true + } + if (!matchesRecorded(identity)) return false + const signal = (value: NodeJS.Signals) => { + try { + if (process.platform === "win32") process.kill(identity.pid, value) + else process.kill(-identity.pid, value) + return true + } catch { + return false + } + } + signal("SIGTERM") + const wait = async (attempt = 0): Promise => { + if (!matchesRecorded(identity)) return true + if (attempt >= 100) return false + await Bun.sleep(10) + return wait(attempt + 1) + } + if (await wait()) return true + signal("SIGKILL") + return true } } diff --git a/backend/cli/src/science/kernel/registry.ts b/backend/cli/src/science/kernel/registry.ts index 67af45cd..9b968aa4 100644 --- a/backend/cli/src/science/kernel/registry.ts +++ b/backend/cli/src/science/kernel/registry.ts @@ -3,15 +3,29 @@ import { Provenance } from "@/science/provenance/store" import { ProvenanceEnvelope } from "@/science/provenance/envelope" import { ExecutionAuthority } from "@/project/execution" import { Storage } from "@/storage/storage" +import path from "node:path" import z from "zod" import { KernelEnvironment } from "./types" -import type { ExecuteOptions, ExecuteResult, Kernel, KernelLanguage, KernelManager, KernelStartOptions } from "./types" +import { KernelProcessIdentity } from "./process" +import { Global } from "@/global" +import { FileLease } from "@/util/file-lease" +import { AuthoritySignal } from "@/project/authority-signal" +import type { + ExecuteOptions, + ExecuteResult, + Kernel, + KernelLanguage, + KernelManager, + KernelProcess, + KernelStartOptions, +} from "./types" export type KernelIdentity = { projectID: string sessionID: string name: string language: KernelLanguage + environmentName?: string } type KernelCell = { @@ -55,17 +69,26 @@ type Entry = { lastActivityAt: number | null authority: ExecutionAuthority.Decision | null lastCell: KernelCell | null + process: KernelProcess | null + lease?: AsyncDisposable + claiming?: Promise } type Pending = { identity: KernelIdentity key: string manager: KernelManager - ticket: { cancelled: boolean } + ticket: StartTicket promise: Promise generation: string } +type StartTicket = { + cancelled: boolean + minimumIncarnation: number + incarnation?: number +} + const Persisted = z.object({ version: z.literal(1), identity: z.object({ @@ -73,11 +96,21 @@ const Persisted = z.object({ sessionID: z.string(), name: z.string(), language: z.string(), + environmentName: z.string().optional(), }), state: z.enum(["lazy", "stopped", "crashed"]), incarnation: z.number().int().nullable(), execution_count: z.number().int().nonnegative(), last_activity_at: z.number().nullable(), + process: z + .object({ + pid: z.number().int().positive(), + startedAt: z.number().positive(), + token: z.string().optional(), + ownershipID: z.string().optional(), + }) + .nullable() + .optional(), }) export const KernelStatus = z.object({ @@ -88,6 +121,7 @@ export const KernelStatus = z.object({ sessionID: z.string(), name: z.string(), language: z.string(), + environment_name: z.string(), target: z.object({ kind: z.literal("local"), }), @@ -134,31 +168,25 @@ const records = Instance.state( }), async (value) => { for (const pending of value.starts.values()) pending.ticket.cancelled = true - await Promise.allSettled([...value.starts.values()].map((pending) => pending.manager.release(pending.key))) - await Promise.allSettled([...value.starts.values()].map((pending) => pending.promise)) - await Promise.allSettled( - [...value.entries.values()].map(async (entry) => { - await entry.manager.release(entry.key) - entry.kernel = undefined - entry.state = entry.state === "crashed" ? "crashed" : "stopped" - entry.executionCount = 0 - entry.environment = null - entry.startedAt = null - entry.lastActivityAt = Date.now() - entry.authority = null - entry.lastCell = null - await persist(entry) - }), - ) + const stopped = await Promise.allSettled([...value.entries.values()].map(releaseEntry)) value.entries.clear() value.starts.clear() + const failed = stopped.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (failed.length) { + throw new AggregateError( + failed.map((result) => result.reason), + "One or more kernels could not be safely reclaimed while disposing the project instance.", + ) + } }, ) -const key = (identity: KernelIdentity) => - `kernel-${Bun.hash(`${identity.projectID}\0${identity.sessionID}\0${identity.name}\0${identity.language}`).toString( - 36, - )}` +const key = (identity: KernelIdentity) => { + const environment = identity.environmentName ? `\0${identity.environmentName}` : "" + return `kernel-${Bun.hash( + `${identity.projectID}\0${identity.sessionID}\0${identity.name}\0${identity.language}${environment}`, + ).toString(36)}` +} const manager = (language: KernelLanguage) => { const value = managers.get(language) @@ -173,6 +201,8 @@ const storageKey = (identity: KernelIdentity) => [ key(identity), ] +const leasePath = (id: string) => path.join(Global.Path.data, "kernel-registry", `${id}.lock`) + async function persist(value: Entry) { await Storage.write(storageKey(value.identity), { version: 1, @@ -182,6 +212,7 @@ async function persist(value: Entry) { incarnation: value.incarnation, execution_count: value.executionCount, last_activity_at: value.lastActivityAt, + process: value.kernel?.process ?? value.process, } satisfies z.infer) } @@ -201,6 +232,7 @@ function restore(value: z.infer) { lastActivityAt: value.last_activity_at, authority: null, lastCell: null, + process: value.process ?? null, } records().entries.set(id, entry) return entry @@ -244,11 +276,165 @@ const record = (identity: KernelIdentity) => { lastActivityAt: null, authority: null, lastCell: null, + process: null, } records().entries.set(id, value) return value } +async function releaseLease(value: Entry) { + await value.lease?.[Symbol.asyncDispose]() + value.lease = undefined +} + +function running(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +async function reap(value: Entry) { + const identity = value.process + if (!identity) return + if (!identity.token && running(identity.pid)) { + throw new Error(`Refusing to terminate unverified persisted kernel process ${identity.pid}.`) + } + await KernelProcessIdentity.terminate(identity) + const stopped = async (attempt = 0): Promise => { + if (!KernelProcessIdentity.matchesRecorded(identity)) return true + if (attempt >= 100) return false + await Bun.sleep(10) + return stopped(attempt + 1) + } + if (!(await stopped())) { + throw new Error(`Kernel process ${identity.pid} is still running after an identity-verified termination attempt.`) + } + value.process = null +} + +async function reapCurrent(value: Entry) { + const identity = value.kernel?.process ?? value.process + if (identity) value.process = identity + await reap(value).catch(async (error) => { + await persist(value).catch(() => undefined) + await releaseLease(value) + throw error + }) + return identity +} + +function reserveIncarnation(value: Entry, ticket?: StartTicket) { + if (!ticket) return + if (ticket.incarnation === undefined) { + ticket.incarnation = Math.max(ticket.minimumIncarnation, (value.incarnation ?? 0) + 1) + } + value.incarnation = Math.max(value.incarnation ?? 0, ticket.incarnation) +} + +async function claim(value: Entry, ticket?: StartTicket) { + if (value.lease) { + reserveIncarnation(value, ticket) + return + } + if (value.claiming) { + await value.claiming + reserveIncarnation(value, ticket) + return + } + const pending = (async () => { + value.lease = await FileLease.acquire(leasePath(value.key), 1_000).catch(() => { + throw new Error("This kernel is active in another OpenScience server. Stop it there before starting it here.") + }) + const stored = await Storage.read(storageKey(value.identity)).catch(async (error) => { + if (Storage.NotFoundError.isInstance(error)) return + await releaseLease(value) + throw error + }) + const parsed = Persisted.safeParse(stored) + if (parsed.success) { + value.incarnation = parsed.data.incarnation + value.executionCount = parsed.data.execution_count + value.lastActivityAt = parsed.data.last_activity_at + value.process = parsed.data.process ?? null + } + await reap(value).catch(async (error) => { + await releaseLease(value) + throw error + }) + reserveIncarnation(value, ticket) + })() + value.claiming = pending + await pending.finally(() => { + if (value.claiming === pending) value.claiming = undefined + }) +} + +async function releaseEntry(value: Entry) { + const pending = records().starts.get(value.key) + if (pending) pending.ticket.cancelled = true + // A start with no kernel claim is queued behind the authority mutation that + // invoked this revoker. Waiting for that promise while the mutation still + // owns AuthoritySignal.exclusive would deadlock. Its cancelled ticket makes + // it abort before spawn when it eventually enters the exclusive section. + const entered = !!pending && (!!value.lease || !!value.claiming) + // Reserve the cancelled boot's generation while holding the kernel lease. + // A restart may win the authority lease before this pending start enters its + // own spawn section; without this reservation the replacement reused + // incarnation 1 and looked indistinguishable from the boot it cancelled. + await claim(value, pending?.ticket) + // Reap through the durable ledger while the interpreter leader is still + // alive. Its live descendant closure includes workers that called setsid() + // and left the kernel's process group; killing the manager/leader first + // would reparent those workers and erase the only safe ownership proof. + await reapCurrent(value) + const released = await value.manager.release(value.key).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ) + if (entered) await pending?.promise.catch(() => undefined) + else void pending?.promise.catch(() => undefined) + records().starts.delete(value.key) + // A cancelled startup releases its lease in the pending promise. Reclaim it + // before touching the durable record so a different server cannot start the + // same kernel between cancellation and the final stopped-state write. + if (!value.lease) await claim(value) + // A cancelled startup may have crossed its spawn boundary after the first + // pass. Keep this second pass to reclaim that late durable registration. + const identity = await reapCurrent(value) + if (!released.ok && !identity) { + await releaseLease(value) + throw released.error + } + value.kernel = undefined + value.state = "stopped" + value.executionCount = 0 + value.environment = null + value.startedAt = null + value.lastActivityAt = Date.now() + value.authority = null + value.lastCell = null + value.process = null + await persist(value).catch(async (error) => { + await releaseLease(value) + throw error + }) + await releaseLease(value) +} + +async function releaseEntries(entries: Entry[]) { + const results = await Promise.allSettled(entries.map(releaseEntry)) + const failed = results.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (failed.length) { + throw new AggregateError( + failed.map((result) => result.reason), + "One or more kernels could not be safely reclaimed.", + ) + } +} + async function provenance( identity: KernelIdentity, value: Entry, @@ -306,6 +492,8 @@ async function provenance( kernel: { id: value.key, language: identity.language, + environmentName: identity.environmentName ?? value.environment?.interpreter.name ?? identity.language, + interpreter: value.environment?.interpreter, incarnation: value.incarnation ?? undefined, processID: process?.pid, processStartedAt: process?.startedAt, @@ -340,6 +528,8 @@ async function provenance( ...(origin?.callID !== undefined ? { callID: origin.callID } : {}), kernelID: value.key, kernelName: identity.name, + kernelEnvironment: identity.environmentName ?? value.environment?.interpreter.name ?? identity.language, + interpreter: value.environment?.interpreter, kernelIncarnation: value.incarnation, executionCount: result?.executionCount ?? value.executionCount, outputTypes: result?.outputs.map((item) => item.type) ?? [], @@ -352,14 +542,19 @@ async function provenance( ) } -const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => { +type Handoff = (value: Entry, kernel: Kernel) => void + +const entry = async (identity: KernelIdentity, options?: KernelStartOptions, handoff?: Handoff) => { const authority = await ExecutionAuthority.require({ projectID: identity.projectID, sessionID: identity.sessionID, capability: "kernel", }) const value = await hydrate(identity) - if (value.kernel?.ready && value.authority?.generation === authority.generation) return value + if (value.kernel?.ready && value.authority?.generation === authority.generation) { + handoff?.(value, value.kernel) + return value + } if (value.kernel?.ready) { await value.manager.release(value.key) value.kernel = undefined @@ -369,7 +564,12 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => } if (value.kernel?.crashed) value.state = "crashed" const pending = records().starts.get(value.key) - if (pending?.generation === authority.generation) return pending.promise + if (pending?.generation === authority.generation) { + const active = await pending.promise + if (!active.kernel) throw new Error("Kernel startup completed without a process") + handoff?.(active, active.kernel) + return active + } if (pending) { pending.ticket.cancelled = true await pending.manager.release(pending.key) @@ -377,17 +577,10 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => records().starts.delete(value.key) } - const incarnation = (value.incarnation ?? 0) + 1 - const ticket = { cancelled: false } - value.state = "stopped" - value.kernel = undefined - value.environment = null - value.incarnation = incarnation - value.executionCount = 0 - value.startedAt = null - value.lastActivityAt = Date.now() - value.authority = authority - value.lastCell = null + const ticket: StartTicket = { + cancelled: false, + minimumIncarnation: (value.incarnation ?? 0) + 1, + } const drop = () => { if (records().starts.get(value.key)?.ticket === ticket) records().starts.delete(value.key) } @@ -395,47 +588,89 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => const abort = async () => { drop() await value.manager.release(value.key) + await releaseLease(value) throw new KernelStartupCancelled() } - // Booting runs inside a call so the pending-start record below is claimed in - // this same synchronous block. The boot awaits (persist, then the process - // spawn), and a cell that arrives during one of them has to find the in-flight - // start to queue behind — publishing the record after those awaits let it - // instead see an entry with no start and boot a second incarnation of its own. - const start = (async () => { - await persist(value) - return value.manager.get(value.key, { + // Publish the pending start before acquiring the cross-process authority + // lease. The exclusive section then owns the final authority check, kernel + // claim, child creation, durable process identity, and in-memory handoff as + // one indivisible spawn boundary with trust/filesystem mutations. + const start = AuthoritySignal.exclusive(async () => { + await claim(value, ticket) + const current = await ExecutionAuthority.require({ + projectID: identity.projectID, sessionID: identity.sessionID, - cwd: authority.workspace, + capability: "kernel", + }).catch(async (error) => { + await releaseLease(value) + throw error }) - })().then( - async (kernel) => { - if (stale()) return abort() - value.environment = kernel.environment ?? null - value.authority = authority - value.startedAt = kernel.process?.startedAt ?? Date.now() - value.lastActivityAt = value.startedAt - await persist(value) - if (stale()) return abort() - // Handing the kernel over is the last, synchronous step of the boot. - // `/status` and the ready fast path above both read `value.kernel`, so - // publishing it before the persist above advertised an idle, ready kernel - // while the cell whose request booted it had not reached the execution - // queue yet — a cell arriving in that window took the free slot first. - drop() - value.kernel = kernel - return value - }, - async (error) => { - drop() - value.kernel = undefined - value.authority = authority - value.state = ticket.cancelled ? "stopped" : "crashed" + if (current.generation !== authority.generation || stale()) { + await abort() + } + + value.state = "stopped" + value.kernel = undefined + value.environment = null + value.executionCount = 0 + value.startedAt = null + value.lastActivityAt = Date.now() + value.authority = current + value.lastCell = null + const processOwnership: KernelProcessIdentity.Ownership = { + id: `kernel-${crypto.randomUUID()}`, + projectID: identity.projectID, + sessionID: identity.sessionID, + authorityGeneration: current.generation, + } + return (async () => { await persist(value) - if (ticket.cancelled) throw new KernelStartupCancelled() - throw error - }, - ) + const kernel = await value.manager.get(value.key, { + ...options, + sessionID: identity.sessionID, + cwd: current.workspace, + processOwnership, + }) + const registered = await KernelProcessIdentity.ensureRegistered(kernel.process, processOwnership).catch( + async (error) => { + await value.manager.release(value.key).catch(() => undefined) + throw error + }, + ) + if (!registered) { + await value.manager.release(value.key).catch(() => undefined) + throw new Error("Kernel manager did not expose a process for durable registration") + } + return kernel + })().then( + async (kernel) => { + if (stale()) return abort() + value.environment = kernel.environment ?? null + value.process = kernel.process ?? null + value.authority = current + value.startedAt = kernel.process?.startedAt ?? Date.now() + value.lastActivityAt = value.startedAt + await persist(value) + if (stale()) return abort() + // A booting execute request synchronously reserves its kernel queue slot + // before this ready process becomes visible through status. Otherwise a + // client that reacts to `active` can overtake the cell that did the boot. + handoff?.(value, kernel) + drop() + value.kernel = kernel + return value + }, + async (error) => { + value.kernel = undefined + value.authority = current + value.state = ticket.cancelled ? "stopped" : "crashed" + await persist(value) + await releaseLease(value) + if (ticket.cancelled) throw new KernelStartupCancelled() + throw error + }, + ) + }).finally(drop) records().starts.set(value.key, { identity, key: value.key, @@ -488,14 +723,8 @@ export namespace KernelRuntime { options?: ExecuteOptions, start?: KernelStartOptions, ): Promise { - const value = await entry(identity, start) - const kernel = value.kernel - if (!kernel) throw new Error("Kernel startup completed without a process") - const codeState = ProvenanceEnvelope.code(value.environment?.cwd ?? Instance.directory) - const startedAt = Date.now() - value.lastActivityAt = startedAt const source = options?.origin?.source ?? (identity.name.startsWith("notebook:") ? identity.name.slice(9) : null) - const cell = (): KernelCell => ({ + const cell = (value: Entry): KernelCell => ({ title: options?.origin?.title?.trim().slice(0, 100) || null, source, code: code.length > 12_000 ? `${code.slice(0, 12_000)}\n\n... (truncated)` : code, @@ -504,68 +733,87 @@ export namespace KernelRuntime { messageID: options?.origin?.messageID ?? null, callID: options?.origin?.callID ?? null, }) - const running: { cell?: KernelCell } = {} - return kernel - .execute(code, { + const running: { + cell?: KernelCell + promise?: Promise + startedAt?: number + codeState?: ReturnType + } = {} + const value = await entry(identity, start, (current, kernel) => { + running.startedAt = Date.now() + current.lastActivityAt = running.startedAt + // KernelQueue increments synchronously, so status cannot expose an idle + // process between the startup handoff and this request joining the queue. + running.promise = kernel.execute(code, { ...options, onStart: () => { - running.cell = cell() - value.lastCell = running.cell - value.lastActivityAt = Date.now() + running.cell = cell(current) + current.lastCell = running.cell + current.lastActivityAt = Date.now() options?.onStart?.() }, }) - .then( - async (result) => { - // The count belongs to this cell, so capture it before the awaits below. - // `value.executionCount` is the kernel's running total and every cell - // queued behind this one advances it — reading it back after the persist - // reported the count of whichever cell had most recently finished. - const count = result.executionCount ?? value.executionCount + 1 - value.executionCount = count - const completedAt = Date.now() - value.lastActivityAt = completedAt - const completeCell: KernelCell = { - ...(running.cell ?? cell()), - status: result.ok ? "succeeded" : "failed", - executionCount: count, - } - if (!value.lastCell || value.lastCell === running.cell) value.lastCell = completeCell - await persist(value) - const complete = { ...result, executionCount: count } - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - codeState, - options?.origin, - complete, - ) - return { ...complete, provenanceID: node.id } - }, - async (error) => { - const completedAt = Date.now() - value.lastActivityAt = completedAt - const failedCell: KernelCell = { ...(running.cell ?? cell()), status: "failed" } - if (!value.lastCell || value.lastCell === running.cell) value.lastCell = failedCell - if (kernel.crashed) value.state = "crashed" - await persist(value) - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - codeState, - options?.origin, - undefined, - error, - ) - throw new KernelExecutionError(error, node.id) - }, - ) + // Capture after reserving the queue but before its promise continuation + // can run. Best-effort git inspection therefore remains pre-execution. + running.codeState = ProvenanceEnvelope.code(current.environment?.cwd ?? Instance.directory) + }) + const kernel = value.kernel + const execution = running.promise + const startedAt = running.startedAt + if (!kernel || !execution || startedAt === undefined) { + throw new Error("Kernel startup completed without a queued execution") + } + return execution.then( + async (result) => { + // The count belongs to this cell, so capture it before the awaits below. + // `value.executionCount` is the kernel's running total and every cell + // queued behind this one advances it — reading it back after the persist + // reported the count of whichever cell had most recently finished. + const count = result.executionCount ?? value.executionCount + 1 + value.executionCount = count + const completedAt = Date.now() + value.lastActivityAt = completedAt + const completeCell: KernelCell = { + ...(running.cell ?? cell(value)), + status: result.ok ? "succeeded" : "failed", + executionCount: count, + } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = completeCell + await persist(value) + const complete = { ...result, executionCount: count } + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + running.codeState, + options?.origin, + complete, + ) + return { ...complete, provenanceID: node.id } + }, + async (error) => { + const completedAt = Date.now() + value.lastActivityAt = completedAt + const failedCell: KernelCell = { ...(running.cell ?? cell(value)), status: "failed" } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = failedCell + if (kernel.crashed) value.state = "crashed" + await persist(value) + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + running.codeState, + options?.origin, + undefined, + error, + ) + throw new KernelExecutionError(error, node.id) + }, + ) } export function active(identity: KernelIdentity) { @@ -588,6 +836,7 @@ export namespace KernelRuntime { sessionID: identity.sessionID, name: identity.name, language: identity.language, + environment_name: identity.environmentName ?? identity.language, target: { kind: "local" }, incarnation: value.incarnation, execution_count: value.executionCount, @@ -630,20 +879,7 @@ export namespace KernelRuntime { export async function release(identity: KernelIdentity) { const value = records().entries.get(key(identity)) if (!value) return - const pending = records().starts.get(value.key) - if (pending) pending.ticket.cancelled = true - await value.manager.release(value.key) - await pending?.promise.catch(() => undefined) - records().starts.delete(value.key) - value.kernel = undefined - value.state = "stopped" - value.executionCount = 0 - value.environment = null - value.startedAt = null - value.lastActivityAt = Date.now() - value.authority = null - value.lastCell = null - await persist(value) + await releaseEntry(value) } export async function restart(identity: KernelIdentity, options?: KernelStartOptions) { @@ -682,12 +918,25 @@ export namespace KernelRuntime { } export async function releaseSession(sessionID: string) { + cancelSession(sessionID) + const entries = [...records().entries.values()].filter((value) => value.identity.sessionID === sessionID) + await releaseEntries(entries) + } + + /** Mark in-flight boots synchronously before a deletion waits on the shared + * authority lease. The boot rechecks this ticket after its last awaited + * startup step and cannot hand a deleted session a live interpreter. */ + export function cancelSession(sessionID: string) { const pending = [...records().starts.values()].filter((value) => value.identity.sessionID === sessionID) for (const value of pending) value.ticket.cancelled = true - await Promise.allSettled(pending.map((value) => value.manager.release(value.key))) - await Promise.allSettled(pending.map((value) => value.promise)) - const entries = [...records().entries.values()].filter((value) => value.identity.sessionID === sessionID) - await Promise.allSettled(entries.map((value) => release(value.identity))) + } + + export async function releaseProject(projectID: string) { + await restoreSession(projectID) + const pending = [...records().starts.values()].filter((value) => value.identity.projectID === projectID) + for (const value of pending) value.ticket.cancelled = true + const entries = [...records().entries.values()].filter((value) => value.identity.projectID === projectID) + await releaseEntries(entries) } export async function removeSession(projectID: string, sessionID: string) { diff --git a/backend/cli/src/science/kernel/types.ts b/backend/cli/src/science/kernel/types.ts index c69ac480..a01fe0c8 100644 --- a/backend/cli/src/science/kernel/types.ts +++ b/backend/cli/src/science/kernel/types.ts @@ -23,6 +23,11 @@ export const AtlasEnvironment = { export const KernelEnvironment = z.object({ cwd: z.string(), + interpreter: z.object({ + name: z.string(), + binary: z.string(), + version: z.string().optional(), + }), atlas: z.object({ access: z.literal(AtlasEnvironment.access), credentials: z.literal(AtlasEnvironment.credentials), @@ -99,6 +104,17 @@ export interface KernelStartOptions { env?: Record /** Interpreter binary override (e.g. a specific python/Rscript path). */ binary?: string + /** Stable user-facing name for the selected interpreter environment. */ + environmentName?: string + /** Internal durable process ownership allocated by the registry before the + * interpreter is spawned. Kernel managers should register it immediately + * after spawn and before waiting for the ready handshake. */ + processOwnership?: { + id: string + projectID: string + sessionID: string + authorityGeneration: string + } } export interface KernelProcess { @@ -108,6 +124,8 @@ export interface KernelProcess { startedAt: number /** Platform process-start token used to guard against PID reuse when available. */ token?: string + /** Synced durable ownership record for this process group. */ + ownershipID?: string } /** A single persistent interpreter process. State persists across executes. */ diff --git a/backend/cli/src/science/provenance/envelope.ts b/backend/cli/src/science/provenance/envelope.ts index dd652705..705d2b54 100644 --- a/backend/cli/src/science/provenance/envelope.ts +++ b/backend/cli/src/science/provenance/envelope.ts @@ -69,6 +69,14 @@ export namespace ProvenanceEnvelope { z.object({ id: z.string(), language: z.string(), + environment_name: field(z.string()), + interpreter: field( + z.object({ + name: z.string(), + binary: z.string(), + version: field(z.string()), + }), + ), incarnation: field(z.number().int().positive()), process_id: field(z.number().int().positive()), process_started_at: field(z.string()), @@ -109,14 +117,21 @@ export namespace ProvenanceEnvelope { const binary = Bun.which("git") if (!binary) return const run = (args: string[]) => { - const proc = Bun.spawnSync([binary, ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }) - if (!proc.success) return - return proc.stdout.toString().trim() + try { + const proc = Bun.spawnSync([binary, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }) + if (!proc.success) return + return proc.stdout.toString().trim() + } catch { + // Code-state capture is best effort. The repository/cwd may disappear, + // or PATH may rotate between discovery and spawn, while the owning + // execution remains valid and must not be aborted by provenance. + return + } } const repository = run(["remote", "get-url", "origin"]) const branch = run(["branch", "--show-current"]) @@ -182,6 +197,12 @@ export namespace ProvenanceEnvelope { kernel?: { id: string language: string + environmentName?: string + interpreter?: { + name: string + binary: string + version?: string + } incarnation?: number processID?: number processStartedAt?: string | number @@ -227,6 +248,18 @@ export namespace ProvenanceEnvelope { ? available({ id: input.kernel.id, language: input.kernel.language, + environment_name: input.kernel.environmentName + ? available(input.kernel.environmentName) + : unavailable("not_captured"), + interpreter: input.kernel.interpreter + ? available({ + name: input.kernel.interpreter.name, + binary: input.kernel.interpreter.binary, + version: input.kernel.interpreter.version + ? available(input.kernel.interpreter.version) + : unavailable("not_captured"), + }) + : unavailable("not_captured"), incarnation: input.kernel.incarnation === undefined ? unavailable("not_captured") diff --git a/backend/cli/src/science/provenance/store.ts b/backend/cli/src/science/provenance/store.ts index cf197ac8..cc6eac0c 100644 --- a/backend/cli/src/science/provenance/store.ts +++ b/backend/cli/src/science/provenance/store.ts @@ -14,6 +14,7 @@ import fs from "node:fs/promises" import { realpathSync } from "node:fs" import { randomUUID } from "node:crypto" import { Global } from "@/global" +import { FileLease } from "@/util/file-lease" import { OpenScience } from "@/openscience" import { ProjectLegacy } from "@/project/legacy" import type { ProvenanceEnvelope } from "./envelope" @@ -214,6 +215,7 @@ async function mutate(fn: (graph: Graph) => Promise | T): Promise { const task = lock.current .catch(() => undefined) .then(async () => { + await using lease = await FileLease.acquire(`${STORE_PATH}.lock`, 120_000) const graph = await load().catch(preserve) const result = await fn(graph) await save(graph) diff --git a/backend/cli/src/server/routes/file.ts b/backend/cli/src/server/routes/file.ts index 32a4bb9a..2735541e 100644 --- a/backend/cli/src/server/routes/file.ts +++ b/backend/cli/src/server/routes/file.ts @@ -16,6 +16,7 @@ import { ArtifactAnnotation } from "../../file/annotations" import { PublicationReview } from "../../file/review" import { Identifier } from "../../id/id" import { ArtifactStore } from "../../artifact/store" +import { FileTrash } from "../../file/trash" const LineageRun = z.object({ id: z.string(), @@ -244,6 +245,49 @@ export const FileRoutes = lazy(() => return c.json(content) }, ) + .get( + "/file/trash", + describeRoute({ + summary: "List recoverable source files", + description: + "List source and workspace files deleted by approved edit operations during the 30-day recovery window.", + operationId: "file.trash.list", + responses: { + 200: { + description: "Recoverable files", + content: { "application/json": { schema: resolver(FileTrash.Record.array()) } }, + }, + }, + }), + async (c) => c.json(await FileTrash.list(Instance.project.id)), + ) + .post( + "/file/trash/:id/restore", + describeRoute({ + summary: "Restore a deleted source file", + description: + "Restore a source or workspace file during its 30-day recovery window without overwriting an existing path.", + operationId: "file.trash.restore", + responses: { + 200: { + description: "Restored file", + content: { "application/json": { schema: resolver(FileTrash.Record) } }, + }, + 404: { description: "Recoverable file not found" }, + }, + }), + validator("param", z.object({ id: z.string().startsWith("ftr_") })), + validator("json", z.object({ sessionID: Identifier.schema("session") })), + async (c) => { + const result = await FileTrash.restore({ + projectID: Instance.project.id, + sessionID: c.req.valid("json").sessionID, + id: c.req.valid("param").id, + }) + if (!result) return c.json({ error: "Recoverable file not found" }, 404) + return c.json(result) + }, + ) .get( "/file/inspect", describeRoute({ diff --git a/backend/cli/src/server/routes/global.ts b/backend/cli/src/server/routes/global.ts index 7bf0f99b..69ec54da 100644 --- a/backend/cli/src/server/routes/global.ts +++ b/backend/cli/src/server/routes/global.ts @@ -91,9 +91,14 @@ export const GlobalRoutes = lazy(() => ), async (c) => { const input = c.req.valid("json") - const project = await ManagedProject.create(input.name, (created) => - SessionFilesystem.seedProject({ projectID: created.id, grants: input.sources }), - ) + const project = await ManagedProject.create(input.name, async (created) => { + await Instance.provide({ + directory: created.worktree, + fn: async () => { + await SessionFilesystem.seedProject({ projectID: created.id, grants: input.sources }) + }, + }) + }) return c.json(project, 201) }, ) diff --git a/backend/cli/src/server/routes/notebook.ts b/backend/cli/src/server/routes/notebook.ts index 9e9feba3..5db26798 100644 --- a/backend/cli/src/server/routes/notebook.ts +++ b/backend/cli/src/server/routes/notebook.ts @@ -1,26 +1,45 @@ import { Hono, type Context } from "hono" +import { HTTPException } from "hono/http-exception" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { Instance } from "../../project/instance" import "../../tool/notebook" import "../../tool/rkernel" -import type { ExecuteResult, KernelOutput } from "../../science/kernel/types" +import type { ExecuteResult, KernelOutput, KernelStartOptions } from "../../science/kernel/types" import { KernelRuntime, KernelStartupCancelled, KernelStatus, type KernelIdentity } from "../../science/kernel/registry" import { KernelMetrics } from "../../science/kernel/metrics" import { KernelHost } from "../../science/kernel/host" -import { SessionFilesystem } from "../../session/filesystem" import { Identifier } from "../../id/id" import { Session } from "../../session" import { lazy } from "../../util/lazy" import { Storage } from "../../storage/storage" import { CommandRuntime, CommandStatus } from "../../science/command/registry" +import { + KernelEnvironmentName, + KernelEnvironmentUnavailable, + pythonEnvironment, +} from "../../science/kernel/interpreter" const Language = z.enum(["python", "r"]) -const Key = z.object({ +const KeyShape = { sessionID: Identifier.schema("session"), id: z.string().trim().min(1).max(1024), language: Language, -}) + environment: KernelEnvironmentName.optional(), +} +const validateEnvironment = ( + input: { language: z.infer; environment?: string }, + issue: z.RefinementCtx, +) => { + if (input.language === "r" && input.environment && input.environment !== "r") { + issue.addIssue({ + code: "custom", + path: ["environment"], + message: "Named interpreter environments currently support Python; use environment 'r' for R.", + }) + } +} +const Key = z.object(KeyShape).superRefine(validateEnvironment) const List = z.object({ sessionID: Identifier.schema("session").optional(), }) @@ -36,20 +55,42 @@ const KernelParam = z.object({ const CommandParam = z.object({ commandID: z.string().regex(/^command-[a-f0-9-]+$/), }) -const Execute = Key.extend({ - code: z.string().max(2_000_000), - timeout: z.number().int().min(5_000).max(600_000).optional(), -}) +const Execute = z + .object({ + ...KeyShape, + code: z.string().max(2_000_000), + timeout: z.number().int().min(5_000).max(600_000).optional(), + }) + .strict() + .superRefine(validateEnvironment) type Language = z.infer -const identity = (input: { sessionID: string; id: string; language: Language }): KernelIdentity => ({ +const identity = (input: { + sessionID: string + id: string + language: Language + environment?: string +}): KernelIdentity => ({ projectID: Instance.project.id, sessionID: input.sessionID, - name: `notebook:${input.id}`, + name: input.environment ? `environment:${input.environment}` : `notebook:${input.id}`, language: input.language, + environmentName: input.environment && input.environment !== input.language ? input.environment : undefined, }) +const runtime = async (input: KernelIdentity): Promise => { + if (input.language !== "python") return { environmentName: "r" } + try { + return await pythonEnvironment(Instance.directory, input.environmentName ?? "python") + } catch (error) { + if (error instanceof KernelEnvironmentUnavailable) { + throw new HTTPException(400, { message: error.message }) + } + throw error + } +} + const owner = async (c: Context, sessionID: string) => Session.get(sessionID) .then((session) => { @@ -298,7 +339,7 @@ export const NotebookRoutes = lazy(() => if (!input) { return c.json({ error: "kernel_not_found", message: "The kernel does not exist in this session." }, 404) } - return c.json(await KernelRuntime.restart(input, { cwd: await SessionFilesystem.workspace(body.sessionID) })) + return c.json(await KernelRuntime.restart(input, await runtime(input))) }, ) .post( @@ -396,11 +437,12 @@ export const NotebookRoutes = lazy(() => const body = c.req.valid("json") const denied = await owner(c, body.sessionID) if (denied) return denied + const selected = identity(body) const result = await KernelRuntime.execute( - identity(body), + selected, body.code, { timeout: body.timeout, origin: { source: body.id } }, - { cwd: await SessionFilesystem.workspace(body.sessionID) }, + await runtime(selected), ).catch((error) => { if (error instanceof KernelStartupCancelled) return error throw error @@ -450,9 +492,8 @@ export const NotebookRoutes = lazy(() => const denied = await owner(c, body.sessionID) if (denied) return denied await KernelRuntime.restoreSession(Instance.project.id, body.sessionID) - return c.json( - await KernelRuntime.restart(identity(body), { cwd: await SessionFilesystem.workspace(body.sessionID) }), - ) + const selected = identity(body) + return c.json(await KernelRuntime.restart(selected, await runtime(selected))) }, ) .post( diff --git a/backend/cli/src/server/routes/project.ts b/backend/cli/src/server/routes/project.ts index aa846666..fa1a8a68 100644 --- a/backend/cli/src/server/routes/project.ts +++ b/backend/cli/src/server/routes/project.ts @@ -68,7 +68,7 @@ export const ProjectRoutes = lazy(() => describeRoute({ summary: "Inspect project trust", description: - "Inspect whether project-local code may execute. Project code is enabled by default and remains disabled only after an explicit revocation.", + "Inspect whether project-local code may execute. New and relocated projects are untrusted until their canonical root is explicitly approved.", operationId: "project.trust.get", responses: { 200: { diff --git a/backend/cli/src/server/routes/repo.ts b/backend/cli/src/server/routes/repo.ts index 8a282c54..8eeefe30 100644 --- a/backend/cli/src/server/routes/repo.ts +++ b/backend/cli/src/server/routes/repo.ts @@ -12,16 +12,26 @@ * POST /push { directory, branch? } * POST /remote { directory, url } — sets origin (add or replace) * - * `directory` remains supported for legacy clients. Project-aware clients may - * instead send the opaque project selector header/query/body field; any - * directory supplied alongside it is treated only as a checked worktree - * override. + * Every operation requires the opaque project selector. A directory may be + * supplied only as a checked worktree override; a caller-owned directory by + * itself never grants repository execution authority. */ import { Hono } from "hono" import { spawn } from "child_process" import { lazy } from "../../util/lazy" import { projectSelection } from "../project-selection" +import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Project } from "@/project/project" +import { ProjectTrust } from "@/project/trust" +import { AuthoritySignal } from "@/project/authority-signal" +import { Config } from "@/config/config" +import { Sandbox } from "@/sandbox/sandbox" +import { OpenScience } from "@/openscience" +import { CommandRuntime } from "@/science/command/registry" +import { Shell } from "@/shell/shell" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" interface RunResult { code: number @@ -41,37 +51,84 @@ export function assertSafeRemoteUrl(url: unknown): string { return value } -function run(command: string, args: string[], cwd: string, ok: number[] = [0]): Promise { - return new Promise((resolveP, rejectP) => { - const child = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], - cwd, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - // Defense in depth: refuse the code-executing helper transports even if a - // malicious remote URL slips past assertSafeRemoteUrl. - GIT_CONFIG_COUNT: "2", - GIT_CONFIG_KEY_0: "protocol.ext.allow", - GIT_CONFIG_VALUE_0: "never", - GIT_CONFIG_KEY_1: "protocol.fake.allow", - GIT_CONFIG_VALUE_1: "never", - }, +async function run(command: string, args: string[], cwd: string, ok: number[] = [0]): Promise { + const launched = await AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "repository") + const options = await Config.trustedSandbox() + const sandbox = Sandbox.wrapArgv({ + file: command, + args, + workspace: [cwd], + readable: [cwd], + unreadable: OpenScience.kernelSensitivePaths(), + options, }) - let out = "" - let err = "" - child.stdout.on("data", (chunk) => (out += chunk.toString())) - child.stderr.on("data", (chunk) => (err += chunk.toString())) - child.on("error", rejectP) - child.on("close", (code) => { - const result: RunResult = { code: code ?? 1, out: out.trim(), err: err.trim() } - if (ok.includes(result.code)) { - resolveP(result) - return + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + const child = (() => { + try { + return spawn(wrapped.file, wrapped.args, { + stdio: ["ignore", "pipe", "pipe"], + cwd, + env: { + ...OpenScience.kernelEnv(process.env), + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "protocol.ext.allow", + GIT_CONFIG_VALUE_0: "never", + GIT_CONFIG_KEY_1: "protocol.fake.allow", + GIT_CONFIG_VALUE_1: "never", + }, + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error } - rejectP(new Error(result.err || result.out || `${command} exited ${code}`)) + })() + const stop = () => + Shell.killTree(child, { exited: () => child.exitCode !== null, detached: process.platform !== "win32" }) + const output = new Promise((resolve, reject) => { + let out = "" + let err = "" + child.stdout?.on("data", (chunk) => (out += chunk.toString())) + child.stderr?.on("data", (chunk) => (err += chunk.toString())) + child.once("error", reject) + child.once("close", (code) => { + resolve({ code: code ?? 1, out: out.trim(), err: err.trim() }) + }) }) + const registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: "repository", + messageID: "repository", + description: "Repository operation", + command: [command, ...args].join(" "), + }, + child, + stop, + { windowsRelease: wrapped.release }, + ).catch(async (error) => { + if (child.exitCode !== null || child.signalCode !== null) return undefined + await stop() + Sandbox.cleanup(sandbox) + throw error + }) + return { registered, sandbox, stop, output } + }) + + const timeout = new Promise((_resolve, reject) => { + const timer = setTimeout(() => { + void launched.stop().finally(() => reject(new Error(`${command} timed out`))) + }, 120_000) + timer.unref() + launched.output.finally(() => clearTimeout(timer)).catch(() => undefined) + }) + const result = await Promise.race([launched.output, timeout]).finally(() => { + Sandbox.cleanup(launched.sandbox) + if (launched.registered) CommandRuntime.finish(launched.registered.id) }) + if (ok.includes(result.code)) return result + throw new Error(result.err || result.out || `${command} exited ${result.code}`) } const git = (args: string[], directory: string, ok?: number[]) => run("git", args, directory, ok) @@ -209,11 +266,31 @@ async function wrap(fn: () => Promise) { } } +async function within( + selected: Awaited>, + action: (directory: string) => Promise, +) { + if (!selected.project || !selected.directory) { + throw new Error("Repository operations require an opaque project selector") + } + return Instance.provide({ + directory: selected.directory, + init: InstanceBootstrap, + async fn() { + if (Instance.project.id !== selected.project.id) { + throw new Project.MismatchError({ projectID: selected.project.id, directory: Instance.directory }) + } + await ProjectTrust.require(Instance.project, "repository") + return action(Instance.directory) + }, + }) +} + export const RepoRoutes = lazy(() => new Hono() .get("/status", async (c) => { const selected = await projectSelection(c) - const r = await wrap(() => status(selected.directory ?? "")) + const r = await wrap(() => within(selected, status)) return c.json(r.body, r.ok ? 200 : 400) }) .post("/commit", async (c) => { @@ -225,7 +302,7 @@ export const RepoRoutes = lazy(() => projectID: body.projectID ?? body.project, directory: body.directory, }) - const r = await wrap(() => commit(selected.directory ?? "", body.message)) + const r = await wrap(() => within(selected, (directory) => commit(directory, body.message))) return c.json(r.body, r.ok ? 200 : 400) }) .post("/push", async (c) => { @@ -237,7 +314,7 @@ export const RepoRoutes = lazy(() => projectID: body.projectID ?? body.project, directory: body.directory, }) - const r = await wrap(() => push(selected.directory ?? "", body.branch)) + const r = await wrap(() => within(selected, (directory) => push(directory, body.branch))) return c.json(r.body, r.ok ? 200 : 400) }) .post("/remote", async (c) => { @@ -249,7 +326,7 @@ export const RepoRoutes = lazy(() => projectID: body.projectID ?? body.project, directory: body.directory, }) - const r = await wrap(() => setRemote(selected.directory ?? "", body.url)) + const r = await wrap(() => within(selected, (directory) => setRemote(directory, body.url))) return c.json(r.body, r.ok ? 200 : 400) }), ) diff --git a/backend/cli/src/server/routes/settings/compute.ts b/backend/cli/src/server/routes/settings/compute.ts index cd54d3e6..c7fb0e82 100644 --- a/backend/cli/src/server/routes/settings/compute.ts +++ b/backend/cli/src/server/routes/settings/compute.ts @@ -18,7 +18,6 @@ import { JsonStore } from "../../../util/jsonstore" import { SecretFile } from "../../../util/secret-file" import { OpenScience } from "../../../openscience" import { ModalAdapter } from "../../../compute/modal/adapter" -import { ModalPlan } from "../../../compute/modal/plan" import { ModalVolume } from "../../../compute/modal/volume" import { Env } from "../../../env" @@ -62,8 +61,7 @@ async function project(context: Context, fn: () => T): Promise { // Vast.ai, RunPod). The provider API key is encrypted AT REST with a // machine-local AES-256-GCM key (mirroring the credentials route) and is // NEVER returned to the client — only presence + metadata are surfaced. -// • Legacy SSH host profiles retained for migration. Public dispatch stays -// unavailable until the full remote lifecycle is verified end to end. +// • SSH host profiles with pinned host-key identity and governed dispatch. // // Modal credentials are inert and resolve only inside its trusted adapter. // Providers that still run through shipped CLI skills retain their legacy @@ -490,6 +488,20 @@ export namespace ComputeSettings { export async function findSshHost(target: string): Promise { return (await read()).ssh_hosts.find((host) => host.id === target) } + + export async function verifySshHost(target: string, probe: ComputeJobs.Probe): Promise { + if (!probe.ok || !probe.host_key || !probe.fingerprint) throw new Error(`SSH host ${target} was not verified`) + const stored = await update((current) => { + const index = current.ssh_hosts.findIndex((host) => host.id === target) + if (index < 0) throw new Error(`SSH host ${target} was not found`) + current.ssh_hosts[index] = SshHost.parse({ + ...current.ssh_hosts[index]!, + host_key: probe.host_key, + fingerprint: probe.fingerprint, + }) + }) + return view(stored) + } } export const ComputeSettingsRoutes = lazy(() => @@ -704,17 +716,7 @@ export const ComputeSettingsRoutes = lazy(() => ...errors(400), }, }), - validator( - "json", - z.object({ - label: z.string().min(1), - host: z.string().min(1), - user: z.string().optional(), - port: z.number().int().positive().optional(), - scheduler: ComputeJobs.Scheduler.default("none"), - workdir: z.string().optional(), - }), - ), + validator("json", ComputeSettings.SshHost.omit({ id: true, fingerprint: true, host_key: true })), async (c) => c.json(await ComputeSettings.addSshHost(c.req.valid("json"))), ) .post( @@ -734,7 +736,9 @@ export const ComputeSettingsRoutes = lazy(() => async (c) => { const host = await ComputeSettings.findSshHost(c.req.valid("param").id) if (!host) return c.json({ error: "SSH host not found" }, 404) - return c.json(await ComputeJobs.probe(host)) + const probe = await ComputeJobs.probe(host) + if (probe.ok) await ComputeSettings.verifySshHost(host.id, probe) + return c.json(probe) }, ) .delete( @@ -773,12 +777,12 @@ export const ComputeSettingsRoutes = lazy(() => .post( "/jobs/plan", describeRoute({ - summary: "Prepare an exact Modal run plan for approval", + summary: "Prepare an exact remote run plan for approval", operationId: "settings.compute.jobs.plan", responses: { 200: { - description: "Modal run plan", - content: { "application/json": { schema: resolver(ModalPlan.Schema) } }, + description: "Remote run plan", + content: { "application/json": { schema: resolver(ComputeJobs.Plan) } }, }, ...errors(400, 409), }, @@ -788,14 +792,20 @@ export const ComputeSettingsRoutes = lazy(() => async (c) => { return project(c, async () => { const input = c.req.valid("json") - return c.json(await ComputeJobs.plan(input, { modal: await ComputeSettings.modalConfig() })) + const settings = await ComputeSettings.get() + return c.json( + await ComputeJobs.plan(input, { + hosts: settings.ssh_hosts, + modal: input.target.kind === "modal" ? await ComputeSettings.modalConfig() : undefined, + }), + ) }) }, ) .post( "/jobs", describeRoute({ - summary: "Start a local compute job", + summary: "Start a compute job", operationId: "settings.compute.jobs.start", responses: { 200: { description: "Started job", content: { "application/json": { schema: resolver(ComputeJobs.Job) } } }, @@ -807,19 +817,20 @@ export const ComputeSettingsRoutes = lazy(() => async (c) => { return project(c, async () => { const input = c.req.valid("json") - if (input.target.kind === "ssh") { - return c.json( - { - error: "remote_compute_unavailable", - message: - "SSH dispatch is unavailable until staged inputs, durable remote IDs, reattachment, cancellation, logs, and outputs pass real-host validation.", - }, - 409, - ) + const settings = input.target.kind === "ssh" ? await ComputeSettings.get() : undefined + const sshHostID = input.target.kind === "ssh" ? input.target.host_id : undefined + if (sshHostID && !settings?.ssh_hosts.some((host) => host.id === sshHostID)) { + throw new HTTPException(400, { message: "The selected SSH compute profile was not found." }) } const modal = input.target.kind === "modal" ? await ComputeSettings.modalConfig() : undefined const resolveCredentials = input.target.kind === "modal" ? ComputeSettings.modalResolver() : undefined - return c.json(await ComputeJobs.start(input, { modal, resolveCredentials })) + return c.json( + await ComputeJobs.start(input, { + hosts: settings?.ssh_hosts, + modal, + resolveCredentials, + }), + ) }) }, ) @@ -892,7 +903,7 @@ export const ComputeSettingsRoutes = lazy(() => .post( "/jobs/:id/retry", describeRoute({ - summary: "Retry delivery from a retained Modal resource", + summary: "Retry output delivery from a retained remote resource", operationId: "settings.compute.jobs.retry", responses: { 200: { @@ -912,6 +923,33 @@ export const ComputeSettingsRoutes = lazy(() => }) }, ) + .post( + "/jobs/:id/release", + describeRoute({ + summary: "Release retained compute resources", + operationId: "settings.compute.jobs.release", + responses: { + 200: { + description: "Resources released", + content: { "application/json": { schema: resolver(ComputeJobs.Job) } }, + }, + ...errors(400, 404, 409), + }, + }), + validator("param", z.object({ id: z.string() })), + validator("query", Directory), + async (c) => { + return project(c, async () => { + const job = await ComputeJobs.get(c.req.valid("param").id) + if (!job) return c.json({ error: "Compute job not found" }, 404) + const settings = await ComputeSettings.get() + const provider = settings.providers.find((item) => item.id === "modal") + const resolveCredentials = + job.target.kind === "modal" && provider?.enabled ? ComputeSettings.modalResolver() : undefined + return c.json(await ComputeJobs.release(job.id, { hosts: settings.ssh_hosts, resolveCredentials })) + }) + }, + ) .post( "/jobs/:id/cancel", describeRoute({ diff --git a/backend/cli/src/server/routes/settings/credentials.ts b/backend/cli/src/server/routes/settings/credentials.ts index e0beeab9..74eca6c7 100644 --- a/backend/cli/src/server/routes/settings/credentials.ts +++ b/backend/cli/src/server/routes/settings/credentials.ts @@ -27,11 +27,14 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import crypto from "crypto" +import fs from "node:fs/promises" +import { DataRootBarrier } from "@/global/data-root-barrier" import path from "path" import z from "zod" import { Global } from "@/global" import { Env } from "@/env" import { OpenScience } from "@/openscience" +import { CredentialLifecycle } from "@/credentials/lifecycle" import { lazy } from "@/util/lazy" import { JsonStore } from "@/util/jsonstore" import { SecretFile } from "@/util/secret-file" @@ -176,6 +179,7 @@ type Store = z.infer const storePath = path.join(Global.Path.data, "credentials.json") const keyPath = path.join(Global.Path.data, "credentials.key") +const gcpPath = path.join(Global.Path.data, "gcp-service-account.json") async function machineKey(): Promise { return SecretFile.key(keyPath) @@ -311,6 +315,7 @@ function mapServiceEnv(id: string, f: Record): Record secrets: string[] + materializationError?: unknown } async function decryptFields(entry: StoreEntry): Promise> { @@ -319,36 +324,84 @@ async function decryptFields(entry: StoreEntry): Promise> try { out[name] = await decrypt(cipher) } catch { - // Unreadable (rotated key / corrupt) — skip; the UI still shows it "set". + // Unreadable (rotated key / corrupt) — omit from runtime and API state. } } return out } +function validField(id: string, name: string, value: string): boolean { + if (id !== "gcp" || name !== "service_account_json") return true + try { + const parsed: unknown = JSON.parse(value) + return !!parsed && typeof parsed === "object" && !Array.isArray(parsed) + } catch { + return false + } +} + +async function validDecryptedFields(id: string, entry: StoreEntry): Promise> { + const fields = await decryptFields(entry) + return Object.fromEntries(Object.entries(fields).filter(([name, value]) => validField(id, name, value))) +} + +async function atomicSecretWrite(filepath: string, content: string): Promise { + await using operation = await DataRootBarrier.enter(filepath) + const temp = `${filepath}.${process.pid}.${crypto.randomUUID()}.tmp` + await fs.mkdir(path.dirname(filepath), { recursive: true }) + const handle = await fs.open(temp, "wx", 0o600) + await handle + .chmod(0o600) + .then(() => handle.writeFile(content, "utf8")) + .then(() => handle.sync()) + .finally(() => handle.close()) + .catch(async (error) => { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + }) + await fs.rename(temp, filepath).catch(async (error) => { + await fs.rm(temp, { force: true }).catch(() => undefined) + throw error + }) + const directory = await fs.open(path.dirname(filepath), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) +} + /** Decrypt the whole store into canonical env vars + the list of secret-bearing * values to redact. GCP service-account JSON is materialized to a 0600 file. */ async function readDecryptedEnv(): Promise { const store = await readStore() const env: Record = {} const secrets: string[] = [] + let gcp: string | undefined + let materializationError: unknown for (const [id, entry] of Object.entries(store)) { - const fields = await decryptFields(entry) - if (id === "gcp" && fields.service_account_json) { - try { - const file = path.join(Global.Path.data, "gcp-service-account.json") - await Bun.write(file, fields.service_account_json, { mode: 0o600 }) - env.GOOGLE_APPLICATION_CREDENTIALS = file - } catch { - // couldn't write — skip ADC; other GCP vars still apply - } - } + const fields = await validDecryptedFields(id, entry) + if (id === "gcp") gcp = fields.service_account_json const mapped = mapServiceEnv(id, fields) for (const [key, value] of Object.entries(mapped)) { env[key] = value if (!NON_SECRET_ENV.test(key)) secrets.push(value) } } - return { env, secrets } + if (gcp) { + try { + await atomicSecretWrite(gcpPath, gcp) + env.GOOGLE_APPLICATION_CREDENTIALS = gcpPath + secrets.push(gcp) + } catch (error) { + // A failed rotation must not leave the previous service-account document + // live under the canonical path. + await fs.rm(gcpPath, { force: true }).catch(() => undefined) + materializationError = error + } + } else { + // Deleted, corrupt, or undecryptable ciphertext is disconnected state. + // Remove the materialized plaintext before dropping the environment path. + await fs.rm(gcpPath, { force: true }).catch(() => undefined) + } + return { env, secrets, materializationError } } // Env keys this module has set, so a re-apply after save can update our own @@ -359,13 +412,15 @@ const ownedKeys = new Set() * environment so the real consumers use them (see the module header). Explicit * shell exports always win. Registers secret values for redaction. Best-effort; * never throws. Call at boot and after every save/delete. */ -export async function applyCredentialEnv(): Promise { +export async function applyCredentialEnv(options: { strict?: boolean } = {}): Promise { try { - const { env, secrets } = await readDecryptedEnv() + const { env, secrets, materializationError } = await readDecryptedEnv() + const state = { staleChildSnapshot: false } // Drop vars we previously injected that are gone now (credential removed) — // but never touch a key the user exported in their own shell. for (const key of [...ownedKeys]) { if (key in env) continue + state.staleChildSnapshot = true delete process.env[key] try { Env.remove(key) @@ -376,6 +431,7 @@ export async function applyCredentialEnv(): Promise { } for (const [key, value] of Object.entries(env)) { if (process.env[key] && !ownedKeys.has(key)) continue + if (ownedKeys.has(key) && process.env[key] !== value) state.staleChildSnapshot = true process.env[key] = value ownedKeys.add(key) try { @@ -385,8 +441,14 @@ export async function applyCredentialEnv(): Promise { } } OpenScience.registerSecretValues(secrets) - } catch { + if (materializationError && options.strict) { + throw new Error("Google Cloud credentials could not be materialized safely", { cause: materializationError }) + } + return state.staleChildSnapshot + } catch (error) { + if (options.strict) throw error // best-effort; a broken store must not break boot or a save response + return false } } @@ -410,46 +472,51 @@ const ServiceView = z.object({ updated_at: z.string().nullable(), }) -function view(store: Store) { +async function view(store: Store) { const seen = new Set() - const known = CATALOG.map((spec) => { - seen.add(spec.id) - const entry = store[spec.id] - const set = entry ? Object.keys(entry.fields) : [] - return { - id: spec.id, - label: spec.label, - description: spec.description, - category: spec.category, - custom: false, - fields: spec.fields.map((f) => ({ - name: f.name, - label: f.label, - type: f.type, - optional: !!f.optional, - placeholder: f.placeholder, - })), - connected: set.length > 0, - set_fields: set, - updated_at: entry?.updated_at ?? null, - } - }) - const custom = Object.entries(store) - .filter(([id]) => !seen.has(id)) - .map(([id, entry]) => { - const names = Object.keys(entry.fields) + const known = await Promise.all( + CATALOG.map(async (spec) => { + seen.add(spec.id) + const entry = store[spec.id] + const set = entry ? Object.keys(await validDecryptedFields(spec.id, entry)) : [] + const required = spec.fields.filter((field) => !field.optional).map((field) => field.name) return { - id, - label: entry.label ?? id, - description: "Custom credential.", - category: "integration" as const, - custom: true, - fields: names.map((name) => ({ name, label: name, type: "password" as const, optional: false })), - connected: names.length > 0, - set_fields: names, - updated_at: entry.updated_at, + id: spec.id, + label: spec.label, + description: spec.description, + category: spec.category, + custom: false, + fields: spec.fields.map((f) => ({ + name: f.name, + label: f.label, + type: f.type, + optional: !!f.optional, + placeholder: f.placeholder, + })), + connected: required.length ? required.every((field) => set.includes(field)) : set.length > 0, + set_fields: set, + updated_at: entry?.updated_at ?? null, } - }) + }), + ) + const custom = await Promise.all( + Object.entries(store) + .filter(([id]) => !seen.has(id)) + .map(async ([id, entry]) => { + const names = Object.keys(await validDecryptedFields(id, entry)) + return { + id, + label: entry.label ?? id, + description: "Custom credential.", + category: "integration" as const, + custom: true, + fields: names.map((name) => ({ name, label: name, type: "password" as const, optional: false })), + connected: names.length > 0, + set_fields: names, + updated_at: entry.updated_at, + } + }), + ) return [...known, ...custom] } @@ -468,7 +535,7 @@ export const CredentialsRoutes = lazy(() => }, }, }), - async (c) => c.json({ services: view(await readStore()) }), + async (c) => c.json({ services: await view(await readStore()) }), ) .put( "/:id", @@ -503,23 +570,38 @@ export const CredentialsRoutes = lazy(() => const id = c.req.valid("param").id const body = c.req.valid("json") const spec = specFor(id) - const store = await updateStore(async (current) => { - const entry = current[id] ?? { fields: {}, updated_at: new Date().toISOString() } - const fields = { ...entry.fields } - for (const [name, value] of Object.entries(body.fields)) { - const trimmed = value.trim() - if (!trimmed) continue - if (spec && !spec.fields.some((f) => f.name === name)) continue - fields[name] = await encrypt(trimmed) - } - current[id] = { - label: body.label ?? entry.label, - fields, - updated_at: new Date().toISOString(), - } - }) - await applyCredentialEnv() // apply the new secret to the running process - return c.json({ services: view(store) }) + const custom = id.startsWith("custom:") + if (!spec && !/^custom:[a-z0-9][a-z0-9-]{0,63}$/.test(id)) { + return c.json({ error: "Unknown credential service" }, 400) + } + const names = Object.keys(body.fields) + if (custom && names.some((name) => !/^[a-z][a-z0-9_]{0,63}$/.test(name))) { + return c.json({ error: "Custom credential field names must be valid environment fields" }, 400) + } + if (spec && names.some((name) => !spec.fields.some((field) => field.name === name))) { + return c.json({ error: "Credential contains an unknown field" }, 400) + } + const gcp = body.fields.service_account_json?.trim() + if (id === "gcp" && gcp && !validField(id, "service_account_json", gcp)) { + return c.json({ error: "Google Cloud service account credentials must be a JSON object" }, 400) + } + const store = await CredentialLifecycle.mutate(`settings-credential.set:${id}`, () => + updateStore(async (current) => { + const entry = current[id] ?? { fields: {}, updated_at: new Date().toISOString() } + const fields = { ...entry.fields } + for (const [name, value] of Object.entries(body.fields)) { + const trimmed = value.trim() + if (!trimmed) continue + fields[name] = await encrypt(trimmed) + } + current[id] = { + label: body.label ?? entry.label, + fields, + updated_at: new Date().toISOString(), + } + }), + ) + return c.json({ services: await view(store) }) }, ) .delete( @@ -537,11 +619,17 @@ export const CredentialsRoutes = lazy(() => }), validator("param", z.object({ id: z.string() })), async (c) => { - const store = await updateStore((current) => { - delete current[c.req.valid("param").id] - }) - await applyCredentialEnv() // re-sync process env after removal - return c.json({ services: view(store) }) + const id = c.req.valid("param").id + const store = await CredentialLifecycle.mutate(`settings-credential.remove:${id}`, () => + updateStore((current) => { + delete current[id] + }), + ) + return c.json({ services: await view(store) }) }, ), ) + +CredentialLifecycle.onRefresh(async () => { + await applyCredentialEnv({ strict: true }) +}) diff --git a/backend/cli/src/server/routes/settings/local.ts b/backend/cli/src/server/routes/settings/local.ts index c3857eea..faa42a3d 100644 --- a/backend/cli/src/server/routes/settings/local.ts +++ b/backend/cli/src/server/routes/settings/local.ts @@ -1,14 +1,238 @@ import { Hono } from "hono" import { validator } from "hono-openapi" import z from "zod" +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { spawn, type ChildProcess } from "node:child_process" import { lazy } from "../../../util/lazy" import { Log } from "../../../util/log" import { Config } from "../../../config/config" import { Provider } from "../../../provider/provider" import { LocalProvider } from "../../../provider/local" +import { Global } from "../../../global" +import { CredentialProcessLedger } from "../../../credentials/process-ledger" +import { ProcessIdentity } from "../../../process/process-identity" +import { DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX } from "../../../process/darwin-responsibility-launcher" +import { WindowsJobLauncher } from "../../../process/windows-job-launcher" +import { Shell } from "../../../shell/shell" +import { FileLease } from "../../../util/file-lease" const log = Log.create({ service: "settings-local" }) +export namespace LocalRuntime { + const RUNTIME_ENV = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "OLLAMA_HOST", + "OLLAMA_MODELS", + "OLLAMA_ORIGINS", + "OLLAMA_KEEP_ALIVE", + "OLLAMA_NOHISTORY", + "OLLAMA_DEBUG", + "OLLAMA_FLASH_ATTENTION", + "OLLAMA_KV_CACHE_TYPE", + "OLLAMA_MAX_LOADED_MODELS", + "OLLAMA_NUM_PARALLEL", + "OLLAMA_MAX_QUEUE", + "OLLAMA_SCHED_SPREAD", + "OLLAMA_LLM_LIBRARY", + ]) + + interface Managed { + id: string + ledger: string + child: ChildProcess + detached: boolean + identity?: string + release?: string + settled?: { code: number | null; signal: NodeJS.Signals | null; error?: string } + } + + const active = new Map() + + /** Local inference servers get runtime discovery/configuration only. They do + * not inherit LLM keys, cloud credentials, Modal tokens, Atlas/OpenScience + * control-plane variables, dynamic-loader hooks, or language startup hooks. */ + export function environment(source: NodeJS.ProcessEnv = process.env): Record { + const result: Record = {} + for (const [name, value] of Object.entries(source)) { + if (!value) continue + const key = process.platform === "win32" ? name.toUpperCase() : name + if (RUNTIME_ENV.has(key) || key.startsWith("LC_")) result[name] = value + } + return { + ...result, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_TERMINAL_PROMPT: "0", + } + } + + function ledgerID(id: string) { + return `local-runtime-${crypto.createHash("sha256").update(id).digest("hex").slice(0, 32)}` + } + + function lockPath(id: string) { + return path.join(Global.Path.data, "local-runtime", `${crypto.createHash("sha256").update(id).digest("hex")}.lock`) + } + + async function complete(id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await CredentialProcessLedger.complete(id)) return + await Bun.sleep(20) + } + await CredentialProcessLedger.revoke({ id, kind: "local-runtime" }) + } + + async function cleanupGate(release?: string) { + if (!release) return + await Promise.all([ + fs.rm(release, { force: true }).catch(() => undefined), + fs.rm(`${release}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, { force: true }).catch(() => undefined), + ]) + } + + async function stopManaged(value: Managed) { + const failures: unknown[] = [] + await CredentialProcessLedger.revoke({ id: value.ledger, kind: "local-runtime" }).catch((error) => + failures.push(error), + ) + const stillOwned = + value.child.pid && value.identity + ? await CredentialProcessLedger.owns(value.child.pid, value.identity) + : value.identity === undefined + if (stillOwned && value.child.exitCode === null && value.child.signalCode === null) { + await Shell.killTree(value.child, { + detached: value.detached, + exited: () => value.child.exitCode !== null || value.child.signalCode !== null, + }).catch((error) => failures.push(error)) + } + if (active.get(value.id) === value) active.delete(value.id) + await cleanupGate(value.release) + if (failures.length) throw new AggregateError(failures, `Local runtime ${value.id} could not be stopped`) + } + + export async function stop(id: string): Promise { + const value = active.get(id) + if (value) { + await stopManaged(value) + return true + } + return (await CredentialProcessLedger.revoke({ id: ledgerID(id), kind: "local-runtime" })) > 0 + } + + export async function stopAll(): Promise { + const known = [...active.values()] + const recovered = await CredentialProcessLedger.revoke("local-runtime") + await Promise.all(known.map((value) => stopManaged(value))) + return Math.max(recovered, known.length) + } + + export async function start(input: { + id: string + file: string + args: string[] + probe: () => Promise + timeoutMs?: number + }): Promise<{ alreadyRunning: boolean; value: T }> { + await using lease = await FileLease.acquire(lockPath(input.id), (input.timeoutMs ?? 15_000) + 10_000) + const already = await input.probe() + if (already !== null) return { alreadyRunning: true, value: already } + + const current = active.get(input.id) + if (current && !current.settled) await stopManaged(current) + const ledger = ledgerID(input.id) + // Recover exact ownership left by a killed prior server before replacing + // this stable runtime id. A second live server is serialized by the lease. + await CredentialProcessLedger.revoke({ id: ledger, kind: "local-runtime" }) + + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + throw new Error(`Could not capture the Linux server identity for local runtime ${input.id}`) + } + const wrapped = WindowsJobLauncher.wrap({ file: input.file, args: input.args, linuxOwner }) + const detached = process.platform !== "win32" + const child = spawn(wrapped.file, wrapped.args, { + env: environment(), + detached, + windowsHide: true, + stdio: "ignore", + }) + const managed: Managed = { id: input.id, ledger, child, detached, release: wrapped.release } + const completion = new Promise>((resolve) => { + child.once("error", (error) => resolve({ code: null, signal: null, error: error.message })) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + try { + if (!child.pid) throw new Error(`Local runtime ${input.id} started without a process id`) + managed.identity = await CredentialProcessLedger.identity(child.pid) + if (!managed.identity) throw new Error(`Could not establish a safe identity for local runtime ${input.id}`) + const registered = await CredentialProcessLedger.register({ + id: ledger, + kind: "local-runtime", + pid: child.pid, + detached, + identity: managed.identity, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error(`Local runtime ${input.id} exited before durable ownership was established`) + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid) + } + active.set(input.id, managed) + void completion.then(async (settled) => { + managed.settled = settled + if (active.get(input.id) === managed) active.delete(input.id) + await complete(ledger).catch((error) => log.error("local runtime completion failed", { id: input.id, error })) + await cleanupGate(managed.release) + }) + } catch (error) { + await stopManaged(managed).catch(() => undefined) + throw error + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const value = await input.probe() + if (value !== null) { + // Do not report a daemonized/unowned endpoint as an OpenScience-managed + // start. The OS-owned supervisor must still be alive at handoff. + await Bun.sleep(100) + if (!managed.settled && active.get(input.id) === managed) return { alreadyRunning: false, value } + } + if (managed.settled) { + const detail = managed.settled.error || `exit ${managed.settled.code ?? managed.settled.signal ?? "unknown"}` + throw new Error(`Local runtime ${input.id} did not remain under OpenScience ownership (${detail})`) + } + await Bun.sleep(400) + } + await stopManaged(managed) + throw new Error(`Local runtime ${input.id} did not answer within ${input.timeoutMs ?? 15_000}ms`) + } +} + /** Provider ids in config whose baseURL points at the local machine. */ async function configuredLocals() { const config = await Config.get().catch(() => ({}) as any) @@ -62,35 +286,28 @@ export const LocalModelsRoutes = lazy(() => const preset = LocalProvider.PRESETS.find((p) => p.id === id) if (!cmd || !preset) return c.json({ error: `Unknown or non-startable runtime: ${id}` }, 400) - // Already up? Just report the models. - const already = await LocalProvider.probe(preset.baseURL, preset.apiKey) - if (already) return c.json({ id, running: true, alreadyRunning: true, models: already }) - - if (!Bun.which(cmd.bin)) { + const executable = Bun.which(cmd.bin) + if (!executable) { return c.json({ id, running: false, installed: false, install: cmd.install }, 200) } try { - // Detached background server — unref so it outlives / doesn't block this - // request and never keeps the openscience server alive on shutdown. - const proc = Bun.spawn([cmd.bin, ...cmd.serve], { stdout: "ignore", stderr: "ignore", stdin: "ignore" }) - proc.unref?.() - log.info("started local runtime", { id, bin: cmd.bin }) + const started = await LocalRuntime.start({ + id, + file: executable, + args: cmd.serve, + probe: () => LocalProvider.probe(preset.baseURL, preset.apiKey), + }) + if (!started.alreadyRunning) log.info("started owned local runtime", { id, bin: executable }) + return c.json({ + id, + running: true, + ...(started.alreadyRunning ? { alreadyRunning: true } : { started: true }), + models: started.value, + }) } catch (e) { return c.json({ id, running: false, error: e instanceof Error ? e.message : String(e) }, 200) } - - // Poll until the OpenAI endpoint answers (server takes a moment to bind). - const deadline = Date.now() + 15_000 - while (Date.now() < deadline) { - await Bun.sleep(500) - const models = await LocalProvider.probe(preset.baseURL, preset.apiKey) - if (models) return c.json({ id, running: true, started: true, models }) - } - return c.json( - { id, running: false, started: true, error: "started but the endpoint didn't respond in time" }, - 200, - ) }) // Probe the well-known runtimes and report which are running + their models. diff --git a/backend/cli/src/server/routes/settings/sandbox.ts b/backend/cli/src/server/routes/settings/sandbox.ts index 83b175d7..5b704a17 100644 --- a/backend/cli/src/server/routes/settings/sandbox.ts +++ b/backend/cli/src/server/routes/settings/sandbox.ts @@ -11,7 +11,7 @@ const log = Log.create({ service: "settings-sandbox" }) const PatchSchema = z.object({ enabled: z.boolean().optional(), network: z.enum(["allow", "deny"]).optional(), - allowWrite: z.array(z.string()).optional(), + allowWrite: z.array(z.string().trim().min(1).max(4096)).max(64).optional(), onUnavailable: z.enum(["warn", "error", "allow"]).optional(), }) @@ -36,8 +36,15 @@ export const SandboxSettingsRoutes = lazy(() => // Persist a partial config patch (machine-wide / global). .put("/", validator("json", PatchSchema), async (c) => { const patch = c.req.valid("json") + const roots = patch.allowWrite?.map((value) => ({ value, canonical: Sandbox.writableGrant(value) })) + const invalid = roots?.find((value) => !value.canonical) + if (invalid) return c.json({ error: `Writable sandbox path is invalid or over-broad: ${invalid.value}` }, 400) + const next = { + ...patch, + ...(roots ? { allowWrite: [...new Set(roots.map((value) => value.canonical!))] } : {}), + } log.info("updating sandbox config", { keys: Object.keys(patch) }) - await Config.setSandbox(patch) + await Config.setSandbox(next) return c.json({ config: await currentConfig(), status: Sandbox.describe() }) }) diff --git a/backend/cli/src/server/routes/settings/storage.ts b/backend/cli/src/server/routes/settings/storage.ts index 5a8e5b41..3b694fb9 100644 --- a/backend/cli/src/server/routes/settings/storage.ts +++ b/backend/cli/src/server/routes/settings/storage.ts @@ -1,45 +1,31 @@ -/** - * Local storage inspector (settings ▸ Storage). Reports the real on-disk - * footprint of Open Science's data directory (and the config/cache/state - * siblings), plus a supported "change data location" operation. - * - * Change-location is a genuine move: it copies the current data directory to - * the chosen target and writes a pointer file (config/data-location) that - * `Global` honours on the next launch — so it takes effect after a restart. - * The original directory is left in place as a safety copy. - */ +/** Local storage usage plus verified live relocation/reset. */ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" -import fs from "fs/promises" -import path from "path" +import fs from "node:fs/promises" +import path from "node:path" import z from "zod" import { Global } from "@/global" +import { DataRelocation } from "@/global/data-relocation" import { lazy } from "@/util/lazy" const pointerPath = path.join(Global.Path.config, "data-location") async function dirSize(target: string): Promise { - let total = 0 - const stack: string[] = [target] - while (stack.length) { - const dir = stack.pop()! - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []) - for (const entry of entries) { - const full = path.join(dir, entry.name) - if (entry.isSymbolicLink()) continue - if (entry.isDirectory()) { - stack.push(full) - continue - } - const stat = await fs.stat(full).catch(() => undefined) - if (stat) total += stat.size - } - } - return total + const entries = await fs.readdir(target, { withFileTypes: true }).catch(() => []) + const sizes = await Promise.all( + entries.map(async (entry) => { + if (entry.isSymbolicLink()) return 0 + const full = path.join(target, entry.name) + if (entry.isDirectory()) return dirSize(full) + return (await fs.stat(full).catch(() => undefined))?.size ?? 0 + }), + ) + return sizes.reduce((sum, size) => sum + size, 0) } const Usage = z.object({ data_dir: z.string(), + managed: z.boolean(), config_dir: z.string(), cache_dir: z.string(), state_dir: z.string(), @@ -48,47 +34,62 @@ const Usage = z.object({ entries: z.array(z.object({ name: z.string(), path: z.string(), bytes: z.number(), kind: z.enum(["dir", "file"]) })), }) +const Moved = z.object({ + ok: z.literal(true), + source: z.string(), + target: z.string(), + files: z.number().int().nonnegative(), + bytes: z.number().int().nonnegative(), + backup: z.string().optional(), + warning: z.string().optional(), +}) + +function message(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + export const StorageRoutes = lazy(() => new Hono() .get( "/", describeRoute({ summary: "Get storage usage", - description: "Real on-disk sizes for the OpenScience data directory and its top-level entries.", + description: "Real on-disk sizes for the active OpenScience data directory and its top-level entries.", operationId: "settings.storage.usage", - responses: { - 200: { - description: "Usage", - content: { "application/json": { schema: resolver(Usage) } }, - }, - }, + responses: { 200: { description: "Usage", content: { "application/json": { schema: resolver(Usage) } } } }, }), async (c) => { - const dataDir = Global.Path.data + const dataDir = await fs.realpath(Global.Path.data) const dirents = await fs.readdir(dataDir, { withFileTypes: true }).catch(() => []) const entries = await Promise.all( dirents - .filter((e) => !e.isSymbolicLink()) - .map(async (e) => { - const full = path.join(dataDir, e.name) - const bytes = e.isDirectory() + .filter((entry) => !entry.isSymbolicLink()) + .map(async (entry) => { + const full = path.join(dataDir, entry.name) + const bytes = entry.isDirectory() ? await dirSize(full) : ((await fs.stat(full).catch(() => undefined))?.size ?? 0) - return { name: e.name, path: full, bytes, kind: e.isDirectory() ? ("dir" as const) : ("file" as const) } + return { + name: entry.name, + path: full, + bytes, + kind: entry.isDirectory() ? ("dir" as const) : ("file" as const), + } }), ) entries.sort((a, b) => b.bytes - a.bytes) const pointer = await Bun.file(pointerPath) .text() - .then((t) => t.trim() || null) + .then((text) => text.trim() || null) .catch(() => null) return c.json({ data_dir: dataDir, + managed: Global.Path.dataManaged, config_dir: Global.Path.config, cache_dir: Global.Path.cache, state_dir: Global.Path.state, pointer, - total_bytes: entries.reduce((sum, e) => sum + e.bytes, 0), + total_bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0), entries, }) }, @@ -98,55 +99,44 @@ export const StorageRoutes = lazy(() => describeRoute({ summary: "Change data location", description: - "Copy the data directory to a new absolute path and record a pointer honoured on next launch. Requires restart.", + "Take a verified snapshot, drain active writers, atomically switch every running OpenScience process, and retain the source as a safety copy.", operationId: "settings.storage.relocate", responses: { - 200: { - description: "Relocated", - content: { - "application/json": { - schema: resolver(z.object({ ok: z.boolean(), target: z.string(), restart_required: z.boolean() })), - }, - }, - }, + 200: { description: "Relocated", content: { "application/json": { schema: resolver(Moved) } } }, + 409: { description: "Relocation could not be completed safely" }, }, }), validator("json", z.object({ path: z.string().min(1) })), async (c) => { const raw = c.req.valid("json").path - const target = path.resolve(raw.replace(/^~(?=$|\/)/, Global.Path.home)) - const source = path.resolve(Global.Path.data) - if (!path.isAbsolute(target)) return c.json({ error: "Path must be absolute" }, 400) - if (target === source) return c.json({ error: "Already the current location" }, 400) - const rel = path.relative(source, target) - if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) - return c.json({ error: "Target cannot be inside the current data directory" }, 400) - - const existing = await fs.readdir(target).catch(() => undefined) - if (existing && existing.length > 0) return c.json({ error: "Target directory is not empty" }, 400) - - await fs.mkdir(target, { recursive: true }) - await fs.cp(source, target, { recursive: true, errorOnExist: false, force: true }) - await Bun.write(pointerPath, target, { mode: 0o600 }) - return c.json({ ok: true, target, restart_required: true }) + if (!path.isAbsolute(raw.replace(/^~(?=$|\/)/, Global.Path.home))) { + return c.json({ error: "Path must be absolute", code: "invalid_storage_location" }, 400) + } + try { + return c.json({ ok: true as const, ...(await DataRelocation.relocate(raw)) }) + } catch (error) { + return c.json({ error: message(error), code: "storage_relocation_failed" }, 409) + } }, ) .delete( "/location", describeRoute({ summary: "Reset data location", - description: "Remove the data-location pointer so ~/.openscience is used on next launch.", + description: + "Reverse-migrate the active data into ~/.openscience, atomically switch every running process, and preserve the previous default as a timestamped backup.", operationId: "settings.storage.resetLocation", responses: { - 200: { - description: "Reset", - content: { "application/json": { schema: resolver(z.object({ ok: z.boolean() })) } }, - }, + 200: { description: "Reset", content: { "application/json": { schema: resolver(Moved) } } }, + 409: { description: "Reset could not be completed safely" }, }, }), async (c) => { - await fs.rm(pointerPath, { force: true }) - return c.json({ ok: true }) + try { + return c.json({ ok: true as const, ...(await DataRelocation.reset()) }) + } catch (error) { + return c.json({ error: message(error), code: "storage_reset_failed" }, 409) + } }, ), ) diff --git a/backend/cli/src/server/routes/settings/updates.ts b/backend/cli/src/server/routes/settings/updates.ts index e5db38f9..2f09d4d0 100644 --- a/backend/cli/src/server/routes/settings/updates.ts +++ b/backend/cli/src/server/routes/settings/updates.ts @@ -6,6 +6,7 @@ import { lazy } from "../../../util/lazy" const RELEASES = "https://github.com/synthetic-sciences/openscience/releases" const RELEASES_API = "https://api.github.com/repos/synthetic-sciences/openscience/releases?per_page=20" +const CACHE_TTL = 5 * 60_000 export function isNewerVersion(current: string, latest: string) { if (current === "local" || current === latest) return false @@ -25,6 +26,62 @@ const Result = z.object({ releaseNotes: z.string().url(), }) +/** + * Deduplicates startup/background update probes without making an explicit + * manual check stale. Failed probes are never retained, so a transient package + * manager or registry failure can be retried immediately. + */ +export function createUpdateCache(input: { load: () => Promise; ttl?: number; now?: () => number }) { + const cache: { value?: Promise; pending?: Promise; expires?: number } = {} + const now = input.now ?? Date.now + + return (refresh = false) => { + const timestamp = now() + if (cache.pending) return cache.pending + if (!refresh && cache.value && cache.expires && cache.expires > timestamp) return cache.value + + const value = Promise.resolve().then(input.load) + cache.value = value + cache.pending = value + cache.expires = timestamp + (input.ttl ?? CACHE_TTL) + void value.then( + () => { + if (cache.pending === value) cache.pending = undefined + }, + () => { + if (cache.pending === value) cache.pending = undefined + if (cache.value !== value) return + cache.value = undefined + cache.expires = undefined + }, + ) + return value + } +} + +// The installation mechanism belongs to the running executable and cannot +// change until this process restarts. Keep that expensive package-manager +// discovery separate so a manual version refresh only rechecks the registry. +const method = createUpdateCache({ + load: Installation.method, + ttl: Number.POSITIVE_INFINITY, +}) + +const update = createUpdateCache({ + load: async () => { + const install = await method() + const latest = await Installation.latest(install) + return Result.parse({ + current: Installation.VERSION, + latest, + channel: Installation.CHANNEL, + method: install, + updateAvailable: isNewerVersion(Installation.VERSION, latest), + releaseNotes: RELEASES, + }) + }, +}) + export const UpdatesSettingsRoutes = lazy(() => new Hono() .get( @@ -40,18 +97,7 @@ export const UpdatesSettingsRoutes = lazy(() => }, }), async (c) => { - const method = await Installation.method() - const latest = await Installation.latest(method) - return c.json( - Result.parse({ - current: Installation.VERSION, - latest, - channel: Installation.CHANNEL, - method, - updateAvailable: isNewerVersion(Installation.VERSION, latest), - releaseNotes: RELEASES, - }), - ) + return c.json(await update(c.req.query("refresh") === "1")) }, ) .get("/releases", async (c) => { diff --git a/backend/cli/src/server/server.ts b/backend/cli/src/server/server.ts index 48596fff..30cbba96 100644 --- a/backend/cli/src/server/server.ts +++ b/backend/cli/src/server/server.ts @@ -61,6 +61,11 @@ import { WalletSettingsRoutes } from "./routes/settings/wallet" import { SettingsUsageRoutes } from "./routes/settings/usage" import { UpdatesSettingsRoutes } from "./routes/settings/updates" import { projectSelection } from "./project-selection" +import { CredentialLifecycle } from "../credentials/lifecycle" +import { ComputeJobs } from "../compute/jobs" +import { CommandRuntime } from "../science/command/registry" +import { CredentialProcessLedger } from "../credentials/process-ledger" +import { DataRootBarrier } from "../global/data-root-barrier" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -71,6 +76,24 @@ export namespace Server { let _url: URL | undefined let _corsWhitelist: string[] = [] let _server: Bun.Server | undefined + let credentialLifecycleReady = false + + function startCredentialLifecycle() { + if (credentialLifecycleReady) return + credentialLifecycleReady = true + CredentialLifecycle.onRevoke(async () => { + // Compute jobs and long-running Bash commands do not live in Instance + // state. MCP and LSP do, and their disposal callbacks close the + // underlying transports/processes. + await Promise.all([ + ComputeJobs.cancelCredentialProcesses(), + CommandRuntime.stopAll(), + CredentialProcessLedger.revoke("mcp"), + Instance.disposeAll(), + ]) + }) + CredentialLifecycle.watch() + } // Per-process secret marking trusted in-process calls (Server.internalFetch). // Generated fresh each run, kept in memory, never sent to any client — a @@ -181,6 +204,16 @@ export namespace Server { }, }), ) + // A live data-root switch publishes an intent, drains these request + // markers, swaps the stable root, then releases waiting requests onto + // the new destination. Keep the switch endpoint itself outside its own + // barrier and avoid pinning long-lived streams/websocket upgrades. + .use(async (c, next) => { + const switching = c.req.path === "/settings/storage/location" + const streaming = c.req.path === "/event" || c.req.path === "/log" || c.req.header("upgrade") === "websocket" + if (switching || streaming) return next() + await DataRootBarrier.during(Global.Path.data, next, 120_000) + }) .route("/global", GlobalRoutes()) .route("/account", AccountRoutes()) // Settings panels backed by global (project-independent) stores, so @@ -721,6 +754,7 @@ export namespace Server { } export function listen(opts: { port: number; cors?: string[] }) { + startCredentialLifecycle() _corsWhitelist = opts.cors ?? [] const args = { diff --git a/backend/cli/src/session/filesystem.ts b/backend/cli/src/session/filesystem.ts index 8b0390af..89fdb4e5 100644 --- a/backend/cli/src/session/filesystem.ts +++ b/backend/cli/src/session/filesystem.ts @@ -4,6 +4,7 @@ import { Global } from "@/global" import { Instance } from "@/project/instance" import { Project } from "@/project/project" import { Storage } from "@/storage/storage" +import { Sandbox } from "@/sandbox/sandbox" import { Filesystem } from "@/util/filesystem" import { Lock } from "@/util/lock" import { NamedError } from "@synsci/util/error" @@ -11,6 +12,7 @@ import crypto from "crypto" import path from "path" import z from "zod" import { SessionWorkspace } from "./workspace" +import { AuthoritySignal } from "@/project/authority-signal" /** * Durable, directional filesystem authority for a session and its project. @@ -73,8 +75,8 @@ export namespace SessionFilesystem { workspace: SessionWorkspace.Info, enforcement: z.object({ broker: z.literal("enforced"), - processWrite: z.literal("workspace_only"), - processRead: z.literal("policy_only"), + processWrite: z.literal("grant_only"), + processRead: z.enum(["grant_only", "policy_only"]), }), }) export type Snapshot = z.infer @@ -111,6 +113,17 @@ export namespace SessionFilesystem { const installationKey = ["installation_filesystem"] const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + async function changed(sessionID: string, projectID: string, grant: Grant) { + const signal = await AuthoritySignal.publish({ + kind: "filesystem", + projectID, + sessionID, + scope: grant.scope, + }) + await Bus.publish(Event.Changed, { sessionID, projectID, grant }) + await AuthoritySignal.settle(signal.revision) + } + async function canonical(input: string, base = Instance.directory) { const target = path.isAbsolute(input) ? input : path.resolve(base, input) const result = await Filesystem.canonical(target) @@ -180,17 +193,17 @@ export namespace SessionFilesystem { if (existing) return assertProject(sessionID, ProjectState.parse(existing)) using _ = await Lock.write(`session-filesystem:project:${Instance.project.id}`) - const current = await load() - if (current) return assertProject(sessionID, ProjectState.parse(current)) - - const record: ProjectState = { - version: 1, - revision: 1, - projectID: Instance.project.id, - grants: [], - } - await Storage.write(projectKey(), record) - return record + const record = await Storage.upsert(projectKey(), (current) => + current + ? ProjectState.parse(current) + : { + version: 1, + revision: 1, + projectID: Instance.project.id, + grants: [], + }, + ) + return assertProject(sessionID, ProjectState.parse(record)) } async function installation() { @@ -203,16 +216,15 @@ export namespace SessionFilesystem { if (existing) return InstallationState.parse(existing) using _ = await Lock.write("session-filesystem:installation") - const current = await load() - if (current) return InstallationState.parse(current) - - const record: InstallationState = { - version: 1, - revision: 1, - grants: [], - } - await Storage.write(installationKey, record) - return record + return Storage.upsert(installationKey, (current) => + current + ? InstallationState.parse(current) + : { + version: 1, + revision: 1, + grants: [], + }, + ) } async function ensure(sessionID: string) { @@ -253,7 +265,7 @@ export namespace SessionFilesystem { return assert(State.parse(await read(sessionID))) } - export async function initialize(sessionID: string, directory: string) { + export async function initialize(sessionID: string, directory: string, options: { revokeExisting?: boolean } = {}) { const root = await canonical(directory) const worktree = await canonical(Instance.worktree) const existing = await read(sessionID).catch((error) => { @@ -287,10 +299,18 @@ export namespace SessionFilesystem { directory: root, grants, } - await Storage.write(key(sessionID), record) + let inserted = false + const stored = await Storage.upsert(key(sessionID), (current) => { + if (current) return State.parse(current) + inserted = true + return record + }) + if (!inserted) return assert(State.parse(stored)).then((value) => workspaceGrant(value)) await project(sessionID) - for (const grant of grants) { - await Bus.publish(Event.Changed, { sessionID, projectID: Instance.project.id, grant }) + if (options.revokeExisting !== false) { + for (const grant of grants) { + await changed(sessionID, Instance.project.id, grant) + } } return grants[0] } @@ -311,39 +331,33 @@ export namespace SessionFilesystem { ) if (roots.length === 0) return - using _ = await Lock.write(`session-filesystem:project:${input.projectID}`) const storage = projectKey(input.projectID) - const existing = await Storage.read(storage).catch((error) => { - if (Storage.NotFoundError.isInstance(error)) return - throw error - }) - const record = existing - ? ProjectState.parse(existing) - : ({ version: 1, revision: 1, projectID: input.projectID, grants: [] } satisfies ProjectState) - if (record.projectID !== input.projectID) throw new InvalidPathError({ path: input.projectID }) - - const additions = roots - .filter( - (root, index, all) => - all.findIndex((item) => item.path === root.path && item.access === root.access) === index && - !record.grants.some( - (grant) => !grant.time.revoked && grant.path === root.path && grant.access === root.access, - ), - ) - .map((root): Grant & { scope: "project" } => ({ - id: `fsg_${crypto.randomUUID()}`, - path: root.path, - access: root.access, - scope: "project", - source: "api", - time: { created: Date.now() }, - })) - if (additions.length === 0) return - await Storage.write(storage, { - ...record, - revision: record.revision + 1, - grants: [...record.grants, ...additions], + let additions: Array = [] + await Storage.upsert(storage, (raw) => { + const record = raw + ? ProjectState.parse(raw) + : ({ version: 1, revision: 1, projectID: input.projectID, grants: [] } satisfies ProjectState) + if (record.projectID !== input.projectID) throw new InvalidPathError({ path: input.projectID }) + additions = roots + .filter( + (root, index, all) => + all.findIndex((item) => item.path === root.path && item.access === root.access) === index && + !record.grants.some( + (grant) => !grant.time.revoked && grant.path === root.path && grant.access === root.access, + ), + ) + .map((root): Grant & { scope: "project" } => ({ + id: `fsg_${crypto.randomUUID()}`, + path: root.path, + access: root.access, + scope: "project", + source: "api", + time: { created: Date.now() }, + })) + if (!additions.length) return record + return { ...record, revision: record.revision + 1, grants: [...record.grants, ...additions] } }) + for (const grant of additions) await changed(`project:${input.projectID}`, input.projectID, grant) } export async function grant(input: { @@ -380,11 +394,7 @@ export namespace SessionFilesystem { draft.revision++ }) const stored = result.grants.find((item) => item.id === grant.id) ?? grant - await Bus.publish(Event.Changed, { - sessionID: input.sessionID, - projectID: Instance.project.id, - grant: stored, - }) + await changed(input.sessionID, Instance.project.id, stored) return stored } if (input.scope === "project") { @@ -404,11 +414,7 @@ export namespace SessionFilesystem { draft.revision++ }) const stored = result.grants.find((item) => item.id === grant.id) ?? grant - await Bus.publish(Event.Changed, { - sessionID: input.sessionID, - projectID: Instance.project.id, - grant: stored, - }) + await changed(input.sessionID, Instance.project.id, stored) return stored } const result = await Storage.update(key(input.sessionID), (draft) => { @@ -429,11 +435,7 @@ export namespace SessionFilesystem { draft.revision++ }) const stored = result.grants.find((item) => item.id === grant.id) ?? grant - await Bus.publish(Event.Changed, { - sessionID: input.sessionID, - projectID: Instance.project.id, - grant: stored, - }) + await changed(input.sessionID, Instance.project.id, stored) return stored } @@ -482,11 +484,7 @@ export namespace SessionFilesystem { draft.revision++ }) grant.time.consumed = consumed - await Bus.publish(Event.Changed, { - sessionID: input.sessionID, - projectID: Instance.project.id, - grant, - }) + await changed(input.sessionID, Instance.project.id, grant) } return { path: target, grant } } @@ -521,12 +519,8 @@ export namespace SessionFilesystem { return result } - /** - * Public policy packet. `processRead: policy_only` is deliberate and honest: - * Seatbelt/bubblewrap readable-mount parity is not safe cross-platform yet, - * so file reads are broker-enforced while arbitrary code retains the existing - * host-readable sandbox model. External writes never become process mounts. - */ + /** Public policy packet. macOS Seatbelt enforces canonical read grants; + * bubblewrap currently retains a read-only host root and reports policy_only. */ export async function snapshot(sessionID: string): Promise { const filesystem = await state(sessionID) const workspace = await SessionWorkspace.touch(sessionID) @@ -535,22 +529,23 @@ export namespace SessionFilesystem { workspace, enforcement: { broker: "enforced", - processWrite: "workspace_only", - processRead: "policy_only", + processWrite: "grant_only", + processRead: Sandbox.describe().readIsolation === "grant_only" ? "grant_only" : "policy_only", }, } } - /** - * Roots arbitrary code may mutate. External grants are intentionally absent: - * even an external write grant means brokered File/Edit/Write mutation, not - * an unrestricted writable Bash/Python/R mount. - */ + /** Persistent explicit read roots for newly launched processes. One-shot + * grants are bound to the single brokered invocation that consumes them. */ + export async function processReadRoots(sessionID: string) { + const record = await state(sessionID) + return record.grants.filter((grant) => grant.scope !== "once" && permits(grant, "read")).map((grant) => grant.path) + } + + /** Persistent explicit write roots for newly launched processes. */ export async function processWriteRoots(sessionID: string) { - const record = await ensure(sessionID) - return record.grants - .filter((grant) => grant.source === "workspace" && grant.scope === "session" && permits(grant, "write")) - .map((grant) => grant.path) + const record = await state(sessionID) + return record.grants.filter((grant) => grant.scope !== "once" && permits(grant, "write")).map((grant) => grant.path) } export async function workspace(sessionID: string) { @@ -559,54 +554,64 @@ export namespace SessionFilesystem { } export async function revoke(sessionID: string, grantID: string) { - const current = await state(sessionID) - const target = current.grants.find((item) => item.id === grantID) - if (!target) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) - const revoked = Date.now() - if (target.scope === "installation") { - const record = await Storage.update(installationKey, (draft) => { - const grant = draft.grants.find((item) => item.id === grantID) - if (!grant) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) - grant.time.revoked = revoked - draft.revision++ - }) - const grant = record.grants.find((item) => item.id === grantID)! - await Bus.publish(Event.Changed, { sessionID, projectID: Instance.project.id, grant }) - return grant - } - if (target.scope === "project") { - const record = await Storage.update(projectKey(), (draft) => { - assertProject(sessionID, ProjectState.parse(draft)) + return AuthoritySignal.exclusive(async () => { + const current = await state(sessionID) + const target = current.grants.find((item) => item.id === grantID) + if (!target) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) + const revoked = Date.now() + if (target.scope === "installation") { + const record = await Storage.update(installationKey, (draft) => { + const grant = draft.grants.find((item) => item.id === grantID) + if (!grant) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) + grant.time.revoked = revoked + draft.revision++ + }) + const grant = record.grants.find((item) => item.id === grantID)! + await changed(sessionID, Instance.project.id, grant) + return grant + } + if (target.scope === "project") { + const record = await Storage.update(projectKey(), (draft) => { + assertProject(sessionID, ProjectState.parse(draft)) + const grant = draft.grants.find((item) => item.id === grantID) + if (!grant) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) + grant.time.revoked = revoked + draft.revision++ + }) + const grant = record.grants.find((item) => item.id === grantID)! + await changed(sessionID, Instance.project.id, grant) + return grant + } + const record = await Storage.update(key(sessionID), (draft) => { const grant = draft.grants.find((item) => item.id === grantID) if (!grant) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) grant.time.revoked = revoked draft.revision++ }) const grant = record.grants.find((item) => item.id === grantID)! - await Bus.publish(Event.Changed, { sessionID, projectID: Instance.project.id, grant }) + await changed(sessionID, Instance.project.id, grant) return grant - } - const record = await Storage.update(key(sessionID), (draft) => { - const grant = draft.grants.find((item) => item.id === grantID) - if (!grant) throw new Storage.NotFoundError({ message: `Filesystem grant not found: ${grantID}` }) - grant.time.revoked = revoked - draft.revision++ }) - const grant = record.grants.find((item) => item.id === grantID)! - await Bus.publish(Event.Changed, { sessionID, projectID: Instance.project.id, grant }) - return grant } export async function remove(sessionID: string) { - const record = await read(sessionID).catch((error) => { - if (Storage.NotFoundError.isInstance(error)) return - throw error + return AuthoritySignal.exclusive(async () => { + const record = await read(sessionID).catch((error) => { + if (Storage.NotFoundError.isInstance(error)) return + throw error + }) + if (record) { + await ensure(sessionID) + await SessionWorkspace.trash(sessionID) + } + await Storage.remove(key(sessionID)) + return AuthoritySignal.publish({ + kind: "filesystem", + projectID: Instance.project.id, + sessionID, + scope: "session", + }) }) - if (record) { - await ensure(sessionID) - await SessionWorkspace.trash(sessionID) - } - await Storage.remove(key(sessionID)) } /** Move stale orphan scratch into recoverable trash and purge trash only diff --git a/backend/cli/src/session/index.ts b/backend/cli/src/session/index.ts index a3fcde2d..db7fc1d7 100644 --- a/backend/cli/src/session/index.ts +++ b/backend/cli/src/session/index.ts @@ -26,6 +26,8 @@ import { Project } from "@/project/project" import { NamedError } from "@synsci/util/error" import { SessionFilesystem } from "./filesystem" import { SessionTraceStore } from "./trace-store" +import { AuthoritySignal } from "@/project/authority-signal" +import { FileLease } from "@/util/file-lease" export namespace Session { const log = Log.create({ service: "session" }) @@ -99,6 +101,26 @@ export namespace Session { }) export type Info = z.output + const Deletion = z.object({ + version: z.literal(1), + info: Info, + time: z.object({ created: z.number().int().positive() }), + }) + type Deletion = z.output + + const deletionKey = (projectID: string, sessionID: string) => ["session_delete", projectID, sessionID] + const deletionLock = (projectID: string, sessionID: string) => + path.join(Global.Path.data, "session-delete", `${projectID}.${sessionID}.lock`) + + async function deleting(projectID: string, sessionID: string) { + return Storage.read(deletionKey(projectID, sessionID)) + .then((value) => Deletion.parse(value)) + .catch((error) => { + if (Storage.NotFoundError.isInstance(error)) return undefined + throw error + }) + } + export const DirectoryMismatchError = NamedError.create( "SessionDirectoryMismatchError", z.object({ @@ -116,6 +138,11 @@ export namespace Session { }), ) + export const DeletingError = NamedError.create( + "SessionDeletingError", + z.object({ sessionID: Identifier.schema("session") }), + ) + const validated = Instance.state(() => new Set()) function current(session: Info) { @@ -263,6 +290,7 @@ export namespace Session { }) { const id = Identifier.descending("session", input.id) const directory = Project.canonicalize(input.directory) + if (await deleting(Instance.project.id, id)) throw new DeletingError({ sessionID: id }) const existing = input.id ? await load(id).catch((error) => { if (Storage.NotFoundError.isInstance(error)) return @@ -294,7 +322,11 @@ export namespace Session { } log.info("created", result) await Storage.write(["session", Instance.project.id, result.id], result) - await SessionFilesystem.initialize(result.id, directory) + // No process can hold authority for a session that has not been returned + // or announced yet. Publishing its initial workspace as a "change" would + // schedule a redundant revocation that can race the session's first job. + // Lazy initialization of legacy sessions keeps the default revocation. + await SessionFilesystem.initialize(result.id, directory, { revokeExisting: false }) validated().add(result.id) Bus.publish(Event.Created, { info: result, @@ -394,31 +426,72 @@ export namespace Session { export const remove = fn(Identifier.schema("session"), async (sessionID) => { const project = Instance.project - const session = await get(sessionID) + await using lease = await FileLease.acquire(deletionLock(project.id, sessionID), 60_000) + let pending = await deleting(project.id, sessionID) + const session = pending?.info ?? (await get(sessionID)) + if (!current(session)) bind(session) try { - for (const child of await children(sessionID)) { - await remove(child.id) + if (!pending) { + // Children must finish their own tombstone/reaper lifecycle before the + // parent becomes unroutable. + for (const child of await children(sessionID)) { + await remove(child.id) + } + pending = { + version: 1, + info: session, + time: { created: Date.now() }, + } + // Publish the recovery record before any destructive mutation. A + // failed reaper or killed deleter can therefore retry by session id. + await Storage.write(deletionKey(project.id, sessionID), pending) } + // Cancellation must be visible before deletion waits for the authority + // lease held by a booting kernel. Otherwise that boot can become ready, + // run its first cell, and only then be reaped by filesystem teardown. + KernelRuntime.cancelSession(sessionID) await unshare(sessionID).catch(() => {}) + // Remove the routable session record before filesystem authority. A + // process start that wins the authority lease first is subsequently + // revoked; one that runs after filesystem removal cannot lazily recreate + // grants from a still-visible session record. The durable tombstone, + // unlike the old ordering, still makes cleanup retryable. + await Storage.remove(["session", project.id, sessionID]) + validated().delete(sessionID) + const signal = await SessionFilesystem.remove(sessionID) + await KernelRuntime.removeSession(project.id, sessionID) + await Bus.publish(Event.Deleted, { + info: session, + }) + await AuthoritySignal.settle(signal.revision) + + // User data is erased only after every runtime reaper acknowledges the + // deletion. A crash during this phase leaves the tombstone last, so the + // remaining idempotent removals are retried on startup. for (const msg of await Storage.list(["message", sessionID])) { for (const part of await Storage.list(["part", msg.at(-1)!])) { await Storage.remove(part) } await Storage.remove(msg) } - await KernelRuntime.removeSession(project.id, sessionID) - await SessionFilesystem.remove(sessionID) await SessionTraceStore.remove(sessionID) - await Storage.remove(["session", project.id, sessionID]) - validated().delete(sessionID) - Bus.publish(Event.Deleted, { - info: session, - }) + await Storage.remove(deletionKey(project.id, sessionID)) } catch (e) { log.error(e) + throw e } }) + /** Resume deletions whose durable tombstone outlived a failed/killed + * deleter. Call only after runtime cleanup subscribers are installed. */ + export async function resumeDeleting() { + const projectID = Instance.project.id + for (const key of await Storage.list(["session_delete", projectID])) { + const sessionID = key.at(-1) + if (sessionID) await remove(sessionID) + } + } + export const updateMessage = fn(MessageV2.Info, async (msg) => { await assertDirectory(msg.sessionID) await Storage.write(["message", msg.sessionID, msg.id], msg) diff --git a/backend/cli/src/session/instruction.ts b/backend/cli/src/session/instruction.ts index 70a773c3..cd046953 100644 --- a/backend/cli/src/session/instruction.ts +++ b/backend/cli/src/session/instruction.ts @@ -7,6 +7,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Log } from "../util/log" import type { MessageV2 } from "./message-v2" +import { Network } from "@/settings/network" const log = Log.create({ service: "instruction" }) @@ -130,7 +131,7 @@ export namespace InstructionPrompt { } } const fetches = urls.map((url) => - fetch(url, { signal: AbortSignal.timeout(5000) }) + Network.fetch(url, { signal: AbortSignal.timeout(5000) }) .then((res) => (res.ok ? res.text() : "")) .catch(() => "") .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), diff --git a/backend/cli/src/session/prompt.ts b/backend/cli/src/session/prompt.ts index 642352b7..f7626bab 100644 --- a/backend/cli/src/session/prompt.ts +++ b/backend/cli/src/session/prompt.ts @@ -40,7 +40,7 @@ import { RLMArtifacts } from "./rlm/artifacts" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" -import { $, fileURLToPath } from "bun" +import { fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { Config } from "../config/config" import { computeBillingMode } from "./billing-gate" @@ -62,6 +62,12 @@ import { PlanMode } from "@/tool/plan-mode" import { Inference } from "@/provider/inference" import { OpenScience } from "@/openscience" import { assertExternalDirectory } from "@/tool/external-directory" +import { CommandRuntime } from "@/science/command/registry" +import { ExecutionAuthority } from "@/project/execution" +import { AuthoritySignal } from "@/project/authority-signal" +import { Sandbox } from "@/sandbox/sandbox" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" +import { BashTool } from "@/tool/bash" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1778,6 +1784,11 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. export async function shell(input: ShellInput) { const session = await Session.get(input.sessionID) const cwd = await SessionFilesystem.workspace(input.sessionID) + const authority = await ExecutionAuthority.require({ + projectID: Instance.project.id, + sessionID: input.sessionID, + capability: "shell", + }) const abort = start(input.sessionID) if (!abort) { throw new Session.BusyError(input.sessionID) @@ -1911,18 +1922,70 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. const matchingInvocation = invocations[shellName] ?? invocations[""] const args = matchingInvocation?.args - const proc = spawn(shell, args, { - cwd, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - env: { - ...(await OpenScience.subprocessEnv(process.env)), - TERM: "dumb", - }, - }) - let output = "" - + let aborted = false + let exited = false + const { proc, command, kill, sandbox, completion } = await AuthoritySignal.exclusive(async () => { + const current = await ExecutionAuthority.require({ + projectID: Instance.project.id, + sessionID: input.sessionID, + capability: "shell", + }) + if (current.generation !== authority.generation) { + throw new Error("Execution authority changed while the shell command was being prepared; retry it") + } + const sandbox = Sandbox.wrapArgv({ + file: shell, + args: args ?? [], + workspace: current.writable, + readable: current.readable, + unreadable: OpenScience.kernelSensitivePaths(), + options: current.sandbox, + }) + return OpenScience.withSubprocessEnv(process.env, async (env) => { + const wrapped = WindowsJobLauncher.wrap({ file: sandbox.file, args: sandbox.args }) + const child = spawn(wrapped.file, wrapped.args, { + cwd, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + env: { ...env, TERM: "dumb" }, + }) + const completion = new Promise((resolve, reject) => { + child.once("close", () => { + exited = true + resolve() + }) + child.once("error", (error) => { + exited = true + reject(error) + }) + }) + const stop = () => Shell.killTree(child, { exited: () => exited, detached: process.platform !== "win32" }) + try { + const registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: input.sessionID, + messageID: msg.id, + callID: part.callID, + description: "User shell command", + command: input.command, + }, + child, + async () => { + aborted = true + await stop() + }, + { authorityGeneration: current.generation, windowsRelease: wrapped.release }, + ) + return { proc: child, command: registered, kill: stop, sandbox, completion } + } catch (error) { + await stop() + Sandbox.cleanup(sandbox) + throw error + } + }) + }) proc.stdout?.on("data", (chunk) => { output += chunk.toString() if (part.state.status === "running") { @@ -1945,11 +2008,6 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. } }) - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited, detached: process.platform !== "win32" }) - if (abort.aborted) { aborted = true await kill() @@ -1962,12 +2020,10 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. abort.addEventListener("abort", abortHandler, { once: true }) - await new Promise((resolve) => { - proc.on("close", () => { - exited = true - abort.removeEventListener("abort", abortHandler) - resolve() - }) + await completion.finally(() => { + abort.removeEventListener("abort", abortHandler) + CommandRuntime.finish(command.id) + Sandbox.cleanup(sandbox) }) if (aborted) { @@ -2131,12 +2187,41 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. template = template + "\n\n" + input.arguments } + const commandMessageID = input.messageID ?? Identifier.ascending("message") const shell = ConfigMarkdown.shell(template) if (shell.length > 0) { + const commandAgent = await Agent.get(agentName) + if (!commandAgent) throw new Error(`Agent not found: "${agentName}"`) + const session = await Session.get(input.sessionID) + const messages = await Array.fromAsync(MessageV2.stream(input.sessionID)) + const bash = await BashTool.init({ agent: commandAgent }) const results = await Promise.all( - shell.map(async ([, cmd]) => { + shell.map(async ([, cmd], index) => { try { - return await $`${{ raw: cmd }}`.quiet().nothrow().text() + const result = await bash.execute( + { + command: cmd, + timeout: 30_000, + description: `Runs command template interpolation ${index + 1}`, + }, + { + sessionID: input.sessionID, + messageID: commandMessageID, + callID: `command-interpolation-${index + 1}`, + agent: commandAgent.name, + abort: new AbortController().signal, + messages, + metadata() {}, + async ask(req) { + await PermissionNext.ask({ + ...req, + sessionID: input.sessionID, + ruleset: PermissionNext.merge(commandAgent.permission, session.permission ?? []), + }) + }, + }, + ) + return result.output } catch (error) { return `Error executing command: ${error instanceof Error ? error.message : String(error)}` } @@ -2219,7 +2304,7 @@ or internal reasoning. Call plan_exit when the plan is ready for approval. const result = (await prompt({ sessionID: input.sessionID, - messageID: input.messageID, + messageID: commandMessageID, model: userModel, agent: userAgent, parts, diff --git a/backend/cli/src/settings/memory.ts b/backend/cli/src/settings/memory.ts index ea6cc7c6..a31e4a09 100644 --- a/backend/cli/src/settings/memory.ts +++ b/backend/cli/src/settings/memory.ts @@ -5,6 +5,7 @@ import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" import { Log } from "../util/log" +import { DataRootBarrier } from "@/global/data-root-barrier" // Persistent, curated memory: standing notes/instructions grouped into // categories that get injected into agent context on every turn (when enabled). @@ -97,6 +98,7 @@ export namespace Memory { export async function set(scope: Scope, doc: Doc): Promise { const file = fileFor(scope) + await using operation = await DataRootBarrier.enter(file) await fs.mkdir(path.dirname(file), { recursive: true }) await Bun.write(file, JSON.stringify(doc, null, 2)) return doc diff --git a/backend/cli/src/settings/network.ts b/backend/cli/src/settings/network.ts index a073250f..f88d4cf8 100644 --- a/backend/cli/src/settings/network.ts +++ b/backend/cli/src/settings/network.ts @@ -1,13 +1,20 @@ import path from "path" import fs from "fs/promises" +import { BlockList, isIP } from "net" +import { lookup } from "node:dns/promises" +import { request as httpRequest } from "node:http" +import { request as httpsRequest } from "node:https" +import { domainToASCII } from "url" import z from "zod" import { Global } from "../global" +import { Lock } from "../util/lock" import { Log } from "../util/log" +import { DataRootBarrier } from "@/global/data-root-barrier" -// Outbound domain allow-list. A catalog of curated science-connector domain -// sets (each toggleable as a group) plus a free-form list of custom domains. -// Persisted as a single JSON document under the ~/.openscience data dir and readable -// by the backend via `Network.allowlist()`. +// Outbound domain allow-list. A catalog of curated science/package domain +// sets (each toggleable as a group) plus a validated list of custom domains. +// The store is an enforcement input, not a presentation preference: malformed +// state fails closed to the curated defaults. export namespace Network { const log = Log.create({ service: "settings.network" }) @@ -19,7 +26,9 @@ export namespace Network { }) export type Group = z.infer - // Curated groups wired to the science connectors the agents actually reach. + // Curated groups wired to the package managers and built-in scientific + // connectors OpenScience actually reaches. Parent domains intentionally + // cover their subdomains; unrelated domains are never implied. export const CATALOG: Group[] = [ { id: "package-management", @@ -27,184 +36,466 @@ export namespace Network { description: "Python, R, JS, Rust package indexes and source hosting.", domains: [ "pypi.org", - "files.pythonhosted.org", - "registry.npmjs.org", - "conda.anaconda.org", - "cran.r-project.org", + "pythonhosted.org", + "npmjs.org", + "yarnpkg.com", + "bun.sh", + "anaconda.org", + "repo.anaconda.com", + "r-project.org", + "posit.co", + "bioconductor.org", + "bioconda.github.io", "crates.io", "github.com", - "raw.githubusercontent.com", - "objects.githubusercontent.com", + "githubusercontent.com", ], }, { id: "ncbi-nih", - label: "NCBI / NIH", - description: "PubMed, Entrez E-utilities, and NIH data services.", - domains: [ - "ncbi.nlm.nih.gov", - "www.ncbi.nlm.nih.gov", - "eutils.ncbi.nlm.nih.gov", - "pubmed.ncbi.nlm.nih.gov", - "ftp.ncbi.nlm.nih.gov", - "nih.gov", - ], + label: "NCBI and NIH", + description: "PubMed, Entrez E-utilities, GEO, dbSNP, ClinVar, and NIH data services.", + domains: ["ncbi.nlm.nih.gov", "nih.gov"], }, { id: "genomics-biology", - label: "Genomics & biology", - description: "Ensembl, UCSC Genome Browser, and EBI resources.", + label: "Genomics and biology", + description: "Ensembl, UCSC, EBI, gnomAD, MyGene, MyVariant, and pathway resources.", domains: [ "ensembl.org", - "rest.ensembl.org", "ucsc.edu", - "genome.ucsc.edu", - "genome-euro.ucsc.edu", + "api.genome.ucsc.edu", "ebi.ac.uk", - "www.ebi.ac.uk", + "gnomad.broadinstitute.org", + "mygene.info", + "myvariant.info", + "webservice.thebiogrid.org", + "rest.kegg.jp", + "string-db.org", + "reactome.org", + "api.platform.opentargets.org", + "wikipathways.org", ], }, { id: "proteomics", - label: "Proteomics", - description: "UniProt, RCSB PDB, and AlphaFold structure services.", - domains: [ - "uniprot.org", - "rest.uniprot.org", - "rcsb.org", - "files.rcsb.org", - "alphafold.ebi.ac.uk", - "www.ebi.ac.uk", - ], + label: "Proteins and structures", + description: "UniProt, RCSB PDB, PDBe, InterPro, SIFTS, and AlphaFold services.", + domains: ["uniprot.org", "rcsb.org", "alphafold.ebi.ac.uk", "ebi.ac.uk"], }, { id: "literature-citations", - label: "Literature & citations", - description: "Preprint servers, Semantic Scholar, Crossref, and DOIs.", + label: "Literature and citations", + description: "Preprint servers, OpenAlex, Semantic Scholar, Crossref, Europe PMC, and DOI resolution.", domains: [ "arxiv.org", + "export.arxiv.org", "biorxiv.org", + "api.biorxiv.org", "medrxiv.org", + "api.medrxiv.org", "semanticscholar.org", - "api.semanticscholar.org", "crossref.org", - "api.crossref.org", "doi.org", "europepmc.org", + "openalex.org", ], }, { - id: "clinical-pharma", - label: "Clinical & pharma", - description: "Clinical trials, drug databases, and regulatory agencies.", - domains: ["clinicaltrials.gov", "go.drugbank.com", "fda.gov", "api.fda.gov", "who.int", "ema.europa.eu"], + id: "chemistry-pharma", + label: "Chemistry and pharmacology", + description: "PubChem, ChEMBL, ChEBI, BindingDB, SureChEMBL, and pharmacology databases.", + domains: ["pubchem.ncbi.nlm.nih.gov", "ebi.ac.uk", "bindingdb.org", "surechembl.org", "guidetopharmacology.org"], + }, + { + id: "omics-atlases", + label: "Omics and atlases", + description: "Expression Atlas, Human Protein Atlas, GTEx, DepMap, ArrayExpress, and cell atlases.", + domains: ["ebi.ac.uk", "proteinatlas.org", "gtexportal.org", "depmap.org", "cellxgene.cziscience.com"], + }, + { + id: "clinical-regulatory", + label: "Clinical and regulatory", + description: "Clinical trials and public regulatory services.", + domains: ["clinicaltrials.gov", "fda.gov", "who.int", "ema.europa.eu"], }, ] - export const State = z.object({ - // When false the allow-list is advisory only (agent may reach any domain). - allowlistEnabled: z.boolean(), - // Enabled catalog group ids. - enabled: z.array(z.string()), - // Custom user-added domains. - custom: z.array(z.string()), - }) - export type State = z.infer - - const file = path.join(Global.Path.data, "settings", "network.json") + const groupIDs = new Set(CATALOG.map((group) => group.id)) - function defaultState(): State { - return { allowlistEnabled: false, enabled: ["package-management"], custom: [] } + /** Parse one custom allow-list entry. Custom entries are deliberately bare + * DNS hostnames: no URL syntax, wildcard, port, IP literal, or local name. */ + export function canonicalDomain(input: string): string { + if (!input || input !== input.trim() || /\s/.test(input)) throw new Error("Domain must not contain whitespace") + if (input.includes("://") || /[\/?#@:*]/.test(input)) { + throw new Error("Enter a bare hostname without a scheme, path, wildcard, credentials, or port") + } + const withoutDot = input.endsWith(".") ? input.slice(0, -1) : input + if (!withoutDot || withoutDot.endsWith(".")) throw new Error("Invalid hostname") + const host = domainToASCII(withoutDot).toLowerCase() + if (!host || host.length > 253 || isIP(host)) throw new Error("IP addresses are not allowed") + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) { + throw new Error("Local and loopback hostnames are not allowed") + } + if (!host.includes(".")) throw new Error("Enter a fully qualified hostname") + const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + if (host.split(".").some((part) => !label.test(part))) throw new Error("Invalid hostname") + return host } - function normalize(domain: string): string { - return domain.trim().toLowerCase().replace(/^\*\./, "").replace(/\.$/, "") + export const Domain = z + .string() + .superRefine((value, ctx) => { + try { + canonicalDomain(value) + } catch (error) { + ctx.addIssue({ code: "custom", message: error instanceof Error ? error.message : "Invalid hostname" }) + } + }) + .transform(canonicalDomain) + + export const State = z + .object({ + // Kept as an explicit escape hatch for trusted machine-level use. New + // installs enforce the curated allow-list by default. + allowlistEnabled: z.boolean(), + enabled: z.array(z.string().refine((id) => groupIDs.has(id), "Unknown network group")), + custom: z.array(Domain), + }) + .strict() + .transform((state) => ({ + ...state, + enabled: [...new Set(state.enabled)], + custom: [...new Set(state.custom)], + })) + export type State = z.output + + const file = path.join(Global.Path.data, "settings", "network.json") + const lock = "settings:network" + + export function defaults(): State { + return { + allowlistEnabled: true, + enabled: CATALOG.map((group) => group.id), + custom: [], + } } function domains(state: State): string[] { - const result = new Set(state.custom.map(normalize).filter(Boolean)) + const result = new Set(state.custom) for (const group of CATALOG) { if (!state.enabled.includes(group.id)) continue - for (const domain of group.domains) result.add(normalize(domain)) + for (const domain of group.domains) result.add(canonicalDomain(domain)) } return [...result].sort() } export function domainAllowed(hostname: string, allowlist: string[]): boolean { - const host = normalize(hostname) - return allowlist.map(normalize).some((domain) => host === domain || host.endsWith(`.${domain}`)) + let host: string + try { + host = canonicalDomain(hostname) + } catch { + return false + } + return allowlist.some((value) => { + try { + const domain = canonicalDomain(value) + return host === domain || host.endsWith(`.${domain}`) + } catch { + return false + } + }) } export async function get(): Promise { const text = await Bun.file(file) .text() .catch(() => undefined) - if (!text) return defaultState() + if (!text) return defaults() try { - const parsed = State.safeParse(JSON.parse(text)) + const raw = JSON.parse(text) as Record + // v1 shipped with enforcement off and only the package group selected. + // That exact unversioned seed was a product default, not an informed + // grant; migrate it to the fail-closed curated v2 default. Explicit v2 + // disable choices remain respected. + if ( + raw.version === undefined && + raw.allowlistEnabled === false && + Array.isArray(raw.enabled) && + raw.enabled.length === 1 && + raw.enabled[0] === "package-management" && + Array.isArray(raw.custom) && + raw.custom.length === 0 + ) { + const migrated = defaults() + await persist(migrated) + return migrated + } + const candidate = raw.version === 2 ? { ...raw, version: undefined } : raw + delete candidate.version + const parsed = State.safeParse(candidate) if (parsed.success) return parsed.data - } catch (e) { - log.error("failed to parse network state", { error: e }) + log.error("invalid network state; using enforced curated defaults", { issues: parsed.error.issues }) + } catch (error) { + log.error("failed to parse network state; using enforced curated defaults", { error }) } - return defaultState() + return defaults() } - export async function set(state: State): Promise { + async function persist(state: State): Promise { + await using operation = await DataRootBarrier.enter(file) await fs.mkdir(path.dirname(file), { recursive: true }) - await Bun.write(file, JSON.stringify(state, null, 2)) + await Bun.write(file, JSON.stringify({ version: 2, ...state }, null, 2)) return state } - // Effective flat list of allowed domains (enabled groups ∪ custom). Readable - // by any backend caller that wants to gate outbound access. + export async function set(input: State): Promise { + const state = State.parse(input) + using _ = await Lock.write(lock) + return persist(state) + } + + // Effective flat list of allowed domains (enabled groups union custom). export async function allowlist(): Promise { return domains(await get()) } - export async function assertAllowed(raw: string): Promise { - const state = await get() - if (!state.allowlistEnabled) return - let url: URL + function url(raw: string): URL { + let result: URL try { - url = new URL(raw) + result = new URL(raw) } catch { throw new Error(`Invalid network URL: ${raw}`) } - if (url.protocol !== "http:" && url.protocol !== "https:") { + if (result.protocol !== "http:" && result.protocol !== "https:") { throw new Error(`Network URL must use http or https: ${raw}`) } - const allowed = domains(state) - if (domainAllowed(url.hostname, allowed)) return - throw new Error(`Network access to ${url.hostname} is not in the configured allow-list`) + if (result.username || result.password) throw new Error("Network URLs must not contain credentials") + // canonicalDomain also rejects literal IPs and local/loopback names. This + // remains mandatory even when the user disables the general allow-list. + canonicalDomain(result.hostname) + return result + } + + export async function assertAllowed(raw: string): Promise { + const state = await get() + const target = url(raw) + if (!state.allowlistEnabled) return + if (domainAllowed(target.hostname, domains(state))) return + throw new Error(`Network access to ${target.hostname} is not in the configured allow-list`) } /** The hostname the allow-list would block for this URL, or undefined when - * the URL is allowed (or enforcement is off). Still throws on invalid URLs - * so callers cannot smuggle malformed input past the gate. */ + * the URL is allowed (or enforcement is off). Invalid/local URLs always + * throw so they cannot be smuggled through a disabled allow-list. */ export async function blocked(raw: string): Promise { const state = await get() - let url: URL - try { - url = new URL(raw) - } catch { - throw new Error(`Invalid network URL: ${raw}`) - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`Network URL must use http or https: ${raw}`) - } + const target = url(raw) + const host = canonicalDomain(target.hostname) if (!state.allowlistEnabled) return undefined - if (domainAllowed(url.hostname, domains(state))) return undefined - return normalize(url.hostname) + if (domainAllowed(host, domains(state))) return undefined + return host } - /** Add one domain to the persisted custom allow-list — the durable half of - * an "always allow" answer to a blocked-domain prompt, so the Network - * settings panel reflects exactly what was granted. */ + /** Add one domain to the persisted custom allow-list. The read-modify-write + * is serialized with Settings PUTs so concurrent approvals cannot clobber + * one another inside the backend process. */ export async function allow(domain: string): Promise { + const host = canonicalDomain(domain) + using _ = await Lock.write(lock) const state = await get() - const host = normalize(domain) - if (!host) return state if (domains(state).includes(host)) return state - return set({ ...state, custom: [...state.custom, host] }) + return persist(State.parse({ ...state, custom: [...state.custom, host] })) + } + + export interface FetchPolicy { + /** Called for a blocked host. Resolving authorizes this request only; an + * "always" permission reply separately persists through Network.allow(). */ + authorize?: (input: { host: string; url: string }) => Promise + maxRedirects?: number + /** Dependency seam for deterministic tests. Production callers omit it + * and use the operating system resolver. */ + resolveAddresses?: (hostname: string) => Promise + /** Test transport seam. Production omits this and uses the pinned socket + * transport below. */ + transport?: (target: URL, init: RequestInit, address: string) => Promise + } + + const nonPublic = new BlockList() + for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], + ] as const) { + nonPublic.addSubnet(network, prefix, "ipv4") + } + for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], + ["2001:db8::", 32], + ] as const) { + nonPublic.addSubnet(network, prefix, "ipv6") + } + + type Resolver = (hostname: string) => Promise + + async function systemResolve(hostname: string): Promise { + return (await lookup(hostname, { all: true, verbatim: true })).map((item) => item.address) + } + + export function addressPublic(address: string) { + const family = isIP(address) + if (!family) return false + return !nonPublic.check(address, family === 4 ? "ipv4" : "ipv6") + } + + async function assertPublicResolution(target: URL, resolveAddresses: Resolver = systemResolve) { + let addresses: readonly string[] + try { + addresses = await resolveAddresses(target.hostname) + } catch (error) { + throw new Error(`Could not safely resolve ${target.hostname}: ${error}`) + } + if (!addresses.length) throw new Error(`Could not safely resolve ${target.hostname}: no addresses returned`) + const blocked = addresses.find((address) => !addressPublic(address)) + if (blocked) throw new Error(`Network access to non-public address ${blocked} for ${target.hostname} is blocked`) + return addresses + } + + function redirected(status: number) { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308 + } + + function withoutSensitiveHeaders(headers: Headers) { + for (const name of ["authorization", "cookie", "proxy-authorization", "referer"]) headers.delete(name) + } + + const originalFetch = globalThis.fetch + + async function pinnedFetch(target: URL, init: RequestInit, address: string): Promise { + const request = new Request(target, init) + const body = request.body ? Buffer.from(await request.arrayBuffer()) : undefined + const headers = Object.fromEntries(request.headers.entries()) + headers.host = target.host + if (body && !request.headers.has("content-length")) headers["content-length"] = String(body.byteLength) + const family = isIP(address) + if (!family) throw new Error(`Resolver returned an invalid address for ${target.hostname}: ${address}`) + + return new Promise((resolve, reject) => { + const send = target.protocol === "https:" ? httpsRequest : httpRequest + const req = send( + target, + { + method: request.method, + headers, + // Keep the original hostname for certificate verification/SNI while + // returning only the validated address to the socket layer. + servername: target.hostname, + lookup: ((_hostname: string, options: { all?: boolean } | number, callback: (...args: unknown[]) => void) => { + if (typeof options === "object" && options.all) { + callback(null, [{ address, family }]) + return + } + callback(null, address, family) + }) as never, + }, + (incoming) => { + const chunks: Buffer[] = [] + incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + incoming.once("error", reject) + incoming.once("end", () => { + const responseHeaders = new Headers() + for (const [name, value] of Object.entries(incoming.headers)) { + if (value === undefined) continue + if (Array.isArray(value)) for (const item of value) responseHeaders.append(name, item) + else responseHeaders.set(name, String(value)) + } + const status = incoming.statusCode ?? 500 + const empty = status === 101 || status === 204 || status === 205 || status === 304 + resolve( + new Response(empty ? null : Buffer.concat(chunks), { + status, + statusText: incoming.statusMessage, + headers: responseHeaders, + }), + ) + }) + }, + ) + req.once("error", reject) + const abort = () => req.destroy(request.signal.reason instanceof Error ? request.signal.reason : undefined) + if (request.signal.aborted) abort() + else request.signal.addEventListener("abort", abort, { once: true }) + if (body) req.end(body) + else req.end() + }) + } + + /** Policy-aware fetch. Every redirect target is re-authorized before the + * socket is opened; cross-origin redirects cannot carry credentials. */ + export async function fetch(raw: string, init: RequestInit = {}, policy: FetchPolicy = {}): Promise { + let target = url(raw) + let method = (init.method ?? "GET").toUpperCase() + let body = init.body + const headers = new Headers(init.headers) + const maxRedirects = policy.maxRedirects ?? 5 + + for (let redirects = 0; ; redirects++) { + const host = await blocked(target.href) + if (host) { + if (!policy.authorize) { + throw new Error(`Network access to ${host} is not in the configured allow-list`) + } + await policy.authorize({ host, url: target.href }) + } + const addresses = await assertPublicResolution(target, policy.resolveAddresses) + const requestInit: RequestInit = { + ...init, + method, + body, + headers, + redirect: "manual", + } + // Unit suites replace global fetch with deterministic in-memory + // transports. Production keeps the original function and therefore + // always takes the address-pinned socket path. + const transport = + policy.transport ?? + (globalThis.fetch !== originalFetch + ? (url: URL, options: RequestInit) => globalThis.fetch(url, options) + : pinnedFetch) + const response = await transport(target, requestInit, addresses[0]!) + const location = response.headers.get("location") + if (!redirected(response.status) || !location) return response + if (redirects >= maxRedirects) { + await response.body?.cancel().catch(() => {}) + throw new Error(`Too many redirects (maximum ${maxRedirects})`) + } + + const next = url(new URL(location, target).href) + if (next.origin !== target.origin) withoutSensitiveHeaders(headers) + if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) { + method = "GET" + body = undefined + headers.delete("content-length") + headers.delete("content-type") + } + await response.body?.cancel().catch(() => {}) + target = next + } } } diff --git a/backend/cli/src/settings/review.ts b/backend/cli/src/settings/review.ts index 5f980493..091bf388 100644 --- a/backend/cli/src/settings/review.ts +++ b/backend/cli/src/settings/review.ts @@ -2,6 +2,7 @@ import path from "path" import fs from "fs/promises" import z from "zod" import { Global } from "../global" +import { DataRootBarrier } from "@/global/data-root-barrier" // Reviewer preferences. Manual review is always available; `auto` opts into // kicking off a reviewer pass automatically after a significant result (a @@ -36,6 +37,7 @@ export namespace ReviewSettings { } export async function set(state: State): Promise { + await using operation = await DataRootBarrier.enter(file) await fs.mkdir(path.dirname(file), { recursive: true }) await Bun.write(file, JSON.stringify(state, null, 2)) return state diff --git a/backend/cli/src/storage/storage.ts b/backend/cli/src/storage/storage.ts index b4da3739..d6c6321c 100644 --- a/backend/cli/src/storage/storage.ts +++ b/backend/cli/src/storage/storage.ts @@ -9,6 +9,7 @@ import { Lock } from "../util/lock" import { $ } from "bun" import { NamedError } from "@synsci/util/error" import z from "zod" +import { DataRootBarrier } from "@/global/data-root-barrier" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -163,7 +164,9 @@ export namespace Storage { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { + await using operation = await DataRootBarrier.enter(target) using _ = await Lock.write(target) + await using __ = await interprocess(target) await fs.unlink(target).catch((error) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return throw error @@ -199,11 +202,46 @@ export namespace Storage { }) } + /** A narrow cross-process lock for storage mutations. OpenScience commonly + * runs a production and development server against one data directory; the + * in-memory Lock cannot serialize those writers. O_EXCL lock creation does, + * while the stale timeout recovers a lock left by a crashed process. */ + async function interprocess(target: string) { + const lockfile = `${target}.lock` + const deadline = Date.now() + 10_000 + await fs.mkdir(path.dirname(target), { recursive: true }) + for (;;) { + try { + const handle = await fs.open(lockfile, "wx", 0o600) + await handle.writeFile(JSON.stringify({ pid: process.pid, created: Date.now() })) + return { + async [Symbol.asyncDispose]() { + await handle.close().catch(() => {}) + await fs.unlink(lockfile).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + }) + }, + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error + const stat = await fs.stat(lockfile).catch(() => undefined) + if (stat && Date.now() - stat.mtimeMs > 30_000) { + await fs.unlink(lockfile).catch(() => {}) + continue + } + if (Date.now() >= deadline) throw new Error(`Timed out waiting for storage mutation lock: ${target}`) + await new Promise((resolve) => setTimeout(resolve, 10 + Math.floor(Math.random() * 20))) + } + } + } + export async function update(key: string[], fn: (draft: T) => void) { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { + await using operation = await DataRootBarrier.enter(target) using _ = await Lock.write(target) + await using __ = await interprocess(target) const content = await Bun.file(target).json() fn(content) await publish(target, JSON.stringify(content, null, 2)) @@ -215,11 +253,36 @@ export namespace Storage { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { + await using operation = await DataRootBarrier.enter(target) using _ = await Lock.write(target) + await using __ = await interprocess(target) await publish(target, JSON.stringify(content, null, 2)) }) } + /** Atomically read-or-create and replace one record under the same + * interprocess lock. Callers use this when computing a revision from the + * previous value; splitting read() + write() would lose concurrent changes. */ + export async function upsert(key: string[], fn: (current: T | undefined) => T): Promise { + const dir = await state().then((x) => x.dir) + const target = path.join(dir, ...key) + ".json" + return withErrorHandling(async () => { + await using operation = await DataRootBarrier.enter(target) + using _ = await Lock.write(target) + await using __ = await interprocess(target) + const current = await Bun.file(target) + .json() + .then((value) => value as T) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + const next = fn(current) + await publish(target, JSON.stringify(next, null, 2)) + return next + }) + } + async function withErrorHandling(body: () => Promise) { return body().catch((e) => { if (!(e instanceof Error)) throw e diff --git a/backend/cli/src/tool/apply_patch.ts b/backend/cli/src/tool/apply_patch.ts index 7ee00fb8..366a440e 100644 --- a/backend/cli/src/tool/apply_patch.ts +++ b/backend/cli/src/tool/apply_patch.ts @@ -1,6 +1,8 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" +import crypto from "node:crypto" +import { constants as FS } from "node:fs" import { Tool } from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" @@ -13,11 +15,112 @@ import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" +import { FileTrash } from "../file/trash" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) +type ApprovedFile = { + bytes: Buffer + content: string + dev: number + ino: number + mode: number +} + +async function readApprovedFile(filepath: string): Promise { + const requested = await fs.lstat(filepath) + if (requested.isSymbolicLink()) throw new Error(`Refusing to edit a symbolic link: ${filepath}`) + const handle = await fs.open(filepath, FS.O_RDONLY | FS.O_NOFOLLOW) + try { + const stat = await handle.stat() + if (!stat.isFile()) throw new Error(`Only regular files can be edited: ${filepath}`) + const bytes = await handle.readFile() + return { + bytes, + content: bytes.toString("utf8"), + dev: stat.dev, + ino: stat.ino, + mode: stat.mode & 0o777, + } + } finally { + await handle.close() + } +} + +async function assertAbsent(filepath: string) { + const exists = await fs.lstat(filepath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false + throw error + }, + ) + if (exists) throw new Error(`Refusing to overwrite an existing file: ${filepath}`) +} + +async function assertApprovedFile(filepath: string, approved: ApprovedFile) { + const current = await readApprovedFile(filepath).catch((error) => { + throw new Error(`Refusing to edit ${filepath}: the file changed after approval: ${error}`) + }) + if (current.dev !== approved.dev || current.ino !== approved.ino) { + throw new Error(`Refusing to edit ${filepath}: the file identity changed after approval`) + } + if (!current.bytes.equals(approved.bytes)) { + throw new Error(`Refusing to edit ${filepath}: the file changed after approval`) + } +} + +async function stageFile(target: string, content: string, mode: number) { + await fs.mkdir(path.dirname(target), { recursive: true }) + const canonical = await Filesystem.canonical(target) + if (!canonical || canonical !== target) throw new Error(`Edit destination became ambiguous: ${target}`) + const staged = path.join(path.dirname(target), `.openscience-edit-${crypto.randomUUID()}.tmp`) + await fs.writeFile(staged, content, { encoding: "utf8", flag: "wx", mode }) + return staged +} + +async function installExclusive(staged: string, target: string) { + try { + // link() is an atomic no-replace install on the target filesystem. + await fs.link(staged, target) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`Refusing to overwrite an existing file: ${target}`) + } + throw error + } +} + +async function applyUpdate(change: { filePath: string; newContent: string; approved: ApprovedFile }) { + const staged = await stageFile(change.filePath, change.newContent, change.approved.mode) + const backup = path.join(path.dirname(change.filePath), `.openscience-approved-${crypto.randomUUID()}.bak`) + let moved = false + let installed = false + try { + await fs.rename(change.filePath, backup) + moved = true + await assertApprovedFile(backup, change.approved) + await installExclusive(staged, change.filePath) + installed = true + await fs.unlink(staged) + await fs.unlink(backup) + } catch (error) { + if (moved && !installed) { + try { + await installExclusive(backup, change.filePath) + await fs.unlink(backup) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `Edit failed; approved original retained at ${backup}`) + } + } + throw error + } finally { + await fs.rm(staged, { force: true }) + } +} + export const ApplyPatchTool = Tool.define("apply_patch", { description: DESCRIPTION, parameters: PatchParams, @@ -43,6 +146,13 @@ export const ApplyPatchTool = Tool.define("apply_patch", { throw new Error("apply_patch verification failed: no hunks found") } + // There is no cross-file filesystem transaction primitive available to + // this broker. Refuse multi-file patches before permission prompts or + // writes so a later-file failure can never leave a partial patch. + if (hunks.length > 1) { + throw new Error("apply_patch verification failed: multi-file patches are not atomic; submit one file per patch") + } + // Validate file paths and check permissions const fileChanges: Array<{ filePath: string @@ -53,6 +163,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff: string additions: number deletions: number + approved?: ApprovedFile }> = [] let totalDiff = "" @@ -63,6 +174,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { switch (hunk.type) { case "add": { + await assertAbsent(filePath) const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` @@ -90,18 +202,15 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } case "update": { - // Check if file exists for update - const stats = await fs.stat(filePath).catch(() => null) - if (!stats || stats.isDirectory()) { - throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) - } - - const oldContent = await fs.readFile(filePath, "utf-8") + const approved = await readApprovedFile(filePath).catch((error) => { + throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}: ${error}`) + }) + const oldContent = approved.content let newContent = oldContent // Apply the update chunks to get new content try { - const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) + const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks, oldContent) newContent = fileUpdate.content } catch (error) { throw new Error(`apply_patch verification failed: ${error}`) @@ -120,6 +229,10 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const movePath = requestedMove ? ((await assertExternalDirectory(ctx, requestedMove, { access: "write" }))?.path ?? requestedMove) : undefined + if (movePath) { + if (movePath === filePath) throw new Error(`apply_patch verification failed: move destination is unchanged`) + await assertAbsent(movePath) + } fileChanges.push({ filePath, @@ -130,6 +243,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff, additions, deletions, + approved, }) totalDiff += diff + "\n" @@ -137,9 +251,10 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } case "delete": { - const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + const approved = await readApprovedFile(filePath).catch((error) => { throw new Error(`apply_patch verification failed: ${error}`) }) + const contentToDelete = approved.content const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length @@ -152,6 +267,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff: deleteDiff, additions: 0, deletions, + approved, }) totalDiff += deleteDiff + "\n" @@ -186,39 +302,78 @@ export const ApplyPatchTool = Tool.define("apply_patch", { }, }) + // Approval is bound to exact source bytes+inode and to an absent add/move + // destination. Revalidate after the user answers, before any side effect. + for (const change of fileChanges) { + if (change.approved) await assertApprovedFile(change.filePath, change.approved) + if (change.type === "add") await assertAbsent(change.filePath) + if (change.type === "move" && change.movePath) await assertAbsent(change.movePath) + } + // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] + const trash: FileTrash.Record[] = [] for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) switch (change.type) { case "add": - // Create parent directories (recursive: true is safe on existing/root dirs) - await fs.mkdir(path.dirname(change.filePath), { recursive: true }) - await fs.writeFile(change.filePath, change.newContent, "utf-8") + { + const staged = await stageFile(change.filePath, change.newContent, 0o644) + try { + await installExclusive(staged, change.filePath) + } finally { + await fs.rm(staged, { force: true }) + } + } updates.push({ file: change.filePath, event: "add" }) break case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") + if (!change.approved) throw new Error(`Missing approved file snapshot for ${change.filePath}`) + await applyUpdate({ filePath: change.filePath, newContent: change.newContent, approved: change.approved }) updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { - // Create parent directories (recursive: true is safe on existing/root dirs) - await fs.mkdir(path.dirname(change.movePath), { recursive: true }) - await fs.writeFile(change.movePath, change.newContent, "utf-8") - await fs.unlink(change.filePath) + if (!change.approved) throw new Error(`Missing approved file snapshot for ${change.filePath}`) + const staged = await stageFile(change.movePath, change.newContent, change.approved.mode) + let removed: FileTrash.Record | undefined + try { + removed = await FileTrash.trash({ + projectID: Instance.project.id, + sessionID: ctx.sessionID, + path: change.filePath, + expectedContent: change.approved.bytes, + }) + try { + await installExclusive(staged, change.movePath) + } catch (error) { + await FileTrash.rollback(removed) + throw error + } + } finally { + await fs.rm(staged, { force: true }) + } + trash.push(removed) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) } break - case "delete": - await fs.unlink(change.filePath) + case "delete": { + if (!change.approved) throw new Error(`Missing approved file snapshot for ${change.filePath}`) + const removed = await FileTrash.trash({ + projectID: Instance.project.id, + sessionID: ctx.sessionID, + path: change.filePath, + expectedContent: change.approved.bytes, + }) + trash.push(removed) updates.push({ file: change.filePath, event: "unlink" }) break + } } if (edited) { @@ -253,6 +408,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { return `M ${path.relative(Instance.worktree, target)}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + if (trash.length) { + output += `\n\nRecoverable for 30 days: ${trash.map((record) => record.id).join(", ")}` + } // Report LSP errors for changed files const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -276,6 +434,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { diff: totalDiff, files, diagnostics, + trash, }, output, } diff --git a/backend/cli/src/tool/bash.ts b/backend/cli/src/tool/bash.ts index b91dd33f..38a8df07 100644 --- a/backend/cli/src/tool/bash.ts +++ b/backend/cli/src/tool/bash.ts @@ -23,6 +23,8 @@ import { Provenance } from "@/science/provenance/store" import { ProvenanceEnvelope } from "@/science/provenance/envelope" import { ExecutionAuthority } from "@/project/execution" import { CommandRuntime } from "@/science/command/registry" +import { AuthoritySignal } from "@/project/authority-signal" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENSCIENCE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 0 @@ -160,6 +162,7 @@ export const BashTool = Tool.define("bash", async () => { capability: "shell", }) const writable = authority.writable + const readable = new Set(authority.readable) const workspace = authority.workspace const requested = params.workdir || workspace const target = path.isAbsolute(requested) ? requested : path.resolve(workspace, requested) @@ -255,11 +258,12 @@ export const BashTool = Tool.define("bash", async () => { }, }, }) - await SessionFilesystem.authorize({ + const authorized = await SessionFilesystem.authorize({ sessionID: ctx.sessionID, path: directory, access, }) + readable.add(authorized.path) } const { existsSync, mkdirSync } = await import("fs") if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }) @@ -277,53 +281,94 @@ export const BashTool = Tool.define("bash", async () => { // provider keys (auth.json + shell env), not just synced managed ones. await OpenScience.refreshByokSecrets(process.env).catch(() => {}) - const env = await OpenScience.subprocessEnv(process.env) - // Wrap the command in the authority's effective OS-sandbox policy. The - // permission checks above decide *whether* to run; this decides *with what - // authority*. An explicit trusted machine-level opt-out returns the raw - // command unchanged. - const sandbox = Sandbox.plan({ - command: params.command, - shell, - cwd, - workspace: writable, - options: authority.sandbox, + // Permission callbacks may durably add the filesystem grant requested + // above. Capture the post-prompt generation so that legitimate grant is + // part of this launch while a later concurrent mutation still fails the + // final check inside the authority lease. + const prepared = await ExecutionAuthority.require({ + projectID: Instance.project.id, + sessionID: ctx.sessionID, + capability: "shell", }) const started = Date.now() - const proc = sandbox.sandboxed - ? spawn(sandbox.file, sandbox.args ?? [], { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }) - : spawn(sandbox.file, { - shell: sandbox.useShell, - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }) - let exited = false let aborted = false - const kill = () => Shell.killTree(proc, { exited: () => exited, detached: process.platform !== "win32" }) - const command = CommandRuntime.start( - { + const { proc, command, kill, sandbox, completion } = await AuthoritySignal.exclusive(async () => { + const current = await ExecutionAuthority.require({ projectID: Instance.project.id, sessionID: ctx.sessionID, - messageID: ctx.messageID, - ...(ctx.callID ? { callID: ctx.callID } : {}), - description: params.description, + capability: "shell", + }) + if (current.generation !== prepared.generation) { + throw new Error("Execution authority changed while the shell command was being prepared; retry it") + } + // Build the wrapper only after the final authority check, while trust + // and filesystem mutations are excluded through durable registration. + const sandbox = Sandbox.plan({ command: params.command, - }, - proc, - async () => { - aborted = true - await kill() - }, - ) + shell, + cwd, + workspace: current.writable, + readable: [...readable], + unreadable: OpenScience.kernelSensitivePaths(), + options: current.sandbox, + }) + return OpenScience.withSubprocessEnv(process.env, async (env) => { + let child: ReturnType + const wrapped = WindowsJobLauncher.wrap({ + file: sandbox.file, + args: sandbox.args ?? [], + shell: sandbox.sandboxed ? false : sandbox.useShell, + }) + try { + child = spawn(wrapped.file, wrapped.args, { + shell: process.platform === "win32" ? false : sandbox.sandboxed ? false : sandbox.useShell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandbox) + throw error + } + const completion = new Promise((resolve, reject) => { + child.once("exit", () => { + exited = true + resolve() + }) + child.once("error", (error) => { + exited = true + reject(error) + }) + }) + const stop = () => Shell.killTree(child, { exited: () => exited, detached: process.platform !== "win32" }) + try { + const registered = await CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + ...(ctx.callID ? { callID: ctx.callID } : {}), + description: params.description, + command: params.command, + }, + child, + async () => { + aborted = true + await stop() + }, + { authorityGeneration: current.generation, windowsRelease: wrapped.release }, + ) + return { proc: child, command: registered, kill: stop, sandbox, completion } + } catch (error) { + await stop() + Sandbox.cleanup(sandbox) + throw error + } + }) + }) let output = "" // Initialize metadata with empty output @@ -385,25 +430,11 @@ export const BashTool = Tool.define("bash", async () => { }, timeout + 100) : undefined - await new Promise((resolve, reject) => { - const cleanup = () => { - if (timeoutTimer) clearTimeout(timeoutTimer) - ctx.abort.removeEventListener("abort", abortHandler) - } - - proc.once("exit", () => { - exited = true - CommandRuntime.finish(command.id) - cleanup() - resolve() - }) - - proc.once("error", (error) => { - exited = true - CommandRuntime.finish(command.id) - cleanup() - reject(error) - }) + await completion.finally(() => { + if (timeoutTimer) clearTimeout(timeoutTimer) + ctx.abort.removeEventListener("abort", abortHandler) + CommandRuntime.finish(command.id) + Sandbox.cleanup(sandbox) }) const completed = Date.now() diff --git a/backend/cli/src/tool/biology/database.ts b/backend/cli/src/tool/biology/database.ts index 06922282..a73c0ec4 100644 --- a/backend/cli/src/tool/biology/database.ts +++ b/backend/cli/src/tool/biology/database.ts @@ -1,5 +1,6 @@ import z from "zod" import { Tool } from "../tool" +import { Network } from "@/settings/network" const TIMEOUT = 30_000 @@ -7,7 +8,7 @@ async function fetchJSON(url: string, init?: RequestInit): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), TIMEOUT) try { - const res = await fetch(url, { + const res = await Network.fetch(url, { ...init, signal: controller.signal, headers: { Accept: "application/json", "User-Agent": "openscience/biology", ...init?.headers }, @@ -23,7 +24,7 @@ async function fetchText(url: string): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), TIMEOUT) try { - const res = await fetch(url, { + const res = await Network.fetch(url, { signal: controller.signal, headers: { "User-Agent": "openscience/biology" }, }) diff --git a/backend/cli/src/tool/biology/notebook.ts b/backend/cli/src/tool/biology/notebook.ts index 94bd8ec8..f83f1702 100644 --- a/backend/cli/src/tool/biology/notebook.ts +++ b/backend/cli/src/tool/biology/notebook.ts @@ -9,6 +9,9 @@ import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" import { Sandbox } from "@/sandbox/sandbox" import { ExecutionAuthority } from "@/project/execution" +import { AuthoritySignal } from "@/project/authority-signal" +import { AuthorityProcessLedger } from "@/project/authority-process" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" const KERNEL_SCRIPT = ` import sys, json, io, traceback, os, re @@ -86,27 +89,52 @@ while True: interface Kernel { process: ChildProcess + projectID: string scriptPath: string configPath: string cachePath: string lastUsed: number generation: string + authorityID: string } const kernels = new Map() +const executionQueues = new Map>() + +async function serialize(sessionID: string, action: () => Promise): Promise { + const previous = executionQueues.get(sessionID) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.catch(() => undefined).then(() => current) + executionQueues.set(sessionID, tail) + await previous.catch(() => undefined) + try { + return await action() + } finally { + release() + if (executionQueues.get(sessionID) === tail) executionQueues.delete(sessionID) + } +} + +function removeKernel(id: string, kernel: Kernel) { + try { + require("fs").unlinkSync(kernel.scriptPath) + } catch {} + try { + require("fs").unlinkSync(kernel.configPath) + } catch {} + rmSync(kernel.cachePath, { recursive: true, force: true }) + if (kernels.get(id) === kernel) kernels.delete(id) + void AuthorityProcessLedger.complete(kernel.authorityID).catch(() => undefined) +} // Clean up all kernels on process exit function cleanupAll() { for (const [id, kernel] of kernels) { Shell.killTreeSync(kernel.process, { detached: process.platform !== "win32" }) - try { - require("fs").unlinkSync(kernel.scriptPath) - } catch {} - try { - require("fs").unlinkSync(kernel.configPath) - } catch {} - rmSync(kernel.cachePath, { recursive: true, force: true }) - kernels.delete(id) + removeKernel(id, kernel) } } @@ -114,27 +142,32 @@ export function shutdownBiologyKernels() { cleanupAll() } +export async function releaseBiologySession(projectID: string, sessionID: string) { + const kernel = kernels.get(sessionID) + if (kernel && kernel.projectID === projectID) { + await AuthorityProcessLedger.revoke({ id: kernel.authorityID, kind: "biology" }) + removeKernel(sessionID, kernel) + } + await AuthorityProcessLedger.revoke({ kind: "biology", projectID, sessionID }) +} + +export async function releaseBiologyProject(projectID: string) { + const sessions = [...kernels].filter(([, kernel]) => kernel.projectID === projectID).map(([sessionID]) => sessionID) + await Promise.all(sessions.map((sessionID) => releaseBiologySession(projectID, sessionID))) + await AuthorityProcessLedger.revoke({ kind: "biology", projectID }) +} + process.on("exit", cleanupAll) process.on("SIGTERM", cleanupAll) process.on("SIGINT", cleanupAll) -function cleanupIdle() { +async function cleanupIdle() { const now = Date.now() const idle = 30 * 60 * 1000 // 30 min for (const [id, kernel] of kernels) { if (now - kernel.lastUsed > idle) { - void Shell.killTree(kernel.process, { - exited: () => kernel.process.exitCode !== null, - detached: process.platform !== "win32", - }) - try { - require("fs").unlinkSync(kernel.scriptPath) - } catch {} - try { - require("fs").unlinkSync(kernel.configPath) - } catch {} - rmSync(kernel.cachePath, { recursive: true, force: true }) - kernels.delete(id) + await AuthorityProcessLedger.revoke({ id: kernel.authorityID, kind: "biology" }) + removeKernel(id, kernel) } } } @@ -146,7 +179,7 @@ async function getKernel(sessionID: string): Promise { capability: "kernel", }) // Clean up idle kernels while we're here - cleanupIdle() + await cleanupIdle() const existing = kernels.get(sessionID) if ( @@ -161,18 +194,8 @@ async function getKernel(sessionID: string): Promise { // Dead kernel — clean up if (existing) { - await Shell.killTree(existing.process, { - exited: () => existing.process.exitCode !== null, - detached: process.platform !== "win32", - }) - try { - require("fs").unlinkSync(existing.scriptPath) - } catch {} - try { - require("fs").unlinkSync(existing.configPath) - } catch {} - rmSync(existing.cachePath, { recursive: true, force: true }) - kernels.delete(sessionID) + await AuthorityProcessLedger.revoke({ id: existing.authorityID, kind: "biology" }) + removeKernel(sessionID, existing) } // Start new kernel @@ -184,31 +207,101 @@ async function getKernel(sessionID: string): Promise { await Bun.write(configPath, "{}\n") const pythonBin = await findPython() - // Confine the kernel to the workspace when the execution sandbox is on: it runs - // arbitrary agent-authored code — the same threat model as the bash tool. - const sandboxed = Sandbox.wrapArgv({ - file: pythonBin, - args: ["-u", scriptPath], - workspace: authority.writable, - extraWritable: [scriptPath, configPath, cachePath], - unreadable: OpenScience.kernelSensitivePaths(), - options: authority.sandbox, - }) - const proc = spawn(sandboxed.file, sandboxed.args, { - cwd: authority.workspace, - env: { - ...OpenScience.kernelEnv(process.env), - ...OpenScience.pythonThreadCapEnv(process.env), - ATLAS_CLI_CONFIG_PATH: configPath, - MPLCONFIGDIR: path.join(cachePath, "matplotlib"), - XDG_CACHE_HOME: path.join(cachePath, "xdg"), - PYTHONPYCACHEPREFIX: path.join(cachePath, "pycache"), - PYTHONUNBUFFERED: "1", - }, - stdio: ["pipe", "pipe", "pipe"], - // Own process group so killing the kernel reaps its joblib/BLAS children (#102). - detached: process.platform !== "win32", + const launched = await AuthoritySignal.exclusive(async () => { + const current = await ExecutionAuthority.require({ + projectID: Instance.project.id, + sessionID, + capability: "kernel", + }) + // Confine the kernel to the workspace when execution sandboxing is on: it + // runs arbitrary agent-authored code and shares Bash's threat model. + const sandboxed = Sandbox.wrapArgv({ + file: pythonBin, + args: ["-u", scriptPath], + workspace: current.writable, + readable: current.readable, + extraWritable: [scriptPath, configPath, cachePath], + unreadable: OpenScience.kernelSensitivePaths(), + options: current.sandbox, + }) + const launch = WindowsJobLauncher.wrap({ file: sandboxed.file, args: sandboxed.args }) + const proc = (() => { + try { + return spawn(launch.file, launch.args, { + cwd: current.workspace, + env: { + ...OpenScience.kernelEnv(process.env), + ...OpenScience.pythonThreadCapEnv(process.env), + ATLAS_CLI_CONFIG_PATH: configPath, + MPLCONFIGDIR: path.join(cachePath, "matplotlib"), + XDG_CACHE_HOME: path.join(cachePath, "xdg"), + PYTHONPYCACHEPREFIX: path.join(cachePath, "pycache"), + PYTHONUNBUFFERED: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + // Own process group so killing the kernel reaps its joblib/BLAS children (#102). + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandboxed) + throw error + } + })() + const authorityID = `biology-${crypto.randomUUID()}` + let exited = false + const complete = () => { + exited = true + Sandbox.cleanup(sandboxed) + void AuthorityProcessLedger.complete(authorityID).catch(() => undefined) + } + proc.once("exit", complete) + proc.once("error", complete) + if (!proc.pid) { + await Shell.killTree(proc, { detached: process.platform !== "win32" }) + Sandbox.cleanup(sandboxed) + throw new Error("Biology kernel started without a process id") + } + const registered = await AuthorityProcessLedger.register({ + id: authorityID, + kind: "biology", + pid: proc.pid, + projectID: Instance.project.id, + sessionID, + authorityGeneration: current.generation, + windowsRelease: launch.release, + }).catch(async (error) => { + await AuthorityProcessLedger.revoke({ id: authorityID, kind: "biology" }).catch(() => undefined) + await Shell.killTree(proc, { + exited: () => proc.exitCode !== null, + detached: process.platform !== "win32", + }) + Sandbox.cleanup(sandboxed) + throw error + }) + if (!registered || exited) { + await AuthorityProcessLedger.revoke({ id: authorityID, kind: "biology" }) + Sandbox.cleanup(sandboxed) + throw new Error("Biology kernel exited before durable authority registration") + } + const kernel: Kernel = { + process: proc, + projectID: Instance.project.id, + scriptPath, + configPath, + cachePath, + lastUsed: Date.now(), + generation: current.generation, + authorityID, + } + kernels.set(sessionID, kernel) + return kernel + }).catch((error) => { + rmSync(scriptPath, { force: true }) + rmSync(configPath, { force: true }) + rmSync(cachePath, { recursive: true, force: true }) + throw error }) + const proc = launched.process // Collect kernel stderr (startup warnings, etc.) let kernelStderr = "" @@ -221,8 +314,10 @@ async function getKernel(sessionID: string): Promise { // Wait for ready signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - void Shell.killTree(proc, { exited: () => proc.exitCode !== null, detached: process.platform !== "win32" }) - reject(new Error(`Kernel startup timed out. stderr: ${kernelStderr}`)) + void AuthorityProcessLedger.revoke({ id: launched.authorityID, kind: "biology" }).then( + () => reject(new Error(`Kernel startup timed out. stderr: ${kernelStderr}`)), + reject, + ) }, 15_000) let buf = "" @@ -245,16 +340,7 @@ async function getKernel(sessionID: string): Promise { }) }) - const kernel: Kernel = { - process: proc, - scriptPath, - configPath, - cachePath, - lastUsed: Date.now(), - generation: authority.generation, - } - kernels.set(sessionID, kernel) - return kernel + return launched } function executeInKernel( @@ -264,12 +350,14 @@ function executeInKernel( ): Promise<{ ok: boolean; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - // Kill the timed-out kernel and any joblib/BLAS workers it started. - void Shell.killTree(kernel.process, { - exited: () => kernel.process.exitCode !== null, - detached: process.platform !== "win32", - }) - reject(new Error(`Cell execution timed out after ${Math.round(timeout / 1000)}s`)) + kernel.process.stdout?.off("data", handler) + kernel.process.off("exit", exitHandler) + // Durable group revocation is identity-checked and includes joblib/BLAS + // workers; reject only after teardown is acknowledged. + void AuthorityProcessLedger.revoke({ id: kernel.authorityID, kind: "biology" }).then( + () => reject(new Error(`Cell execution timed out after ${Math.round(timeout / 1000)}s`)), + reject, + ) }, timeout) let buffer = "" @@ -283,6 +371,7 @@ function executeInKernel( if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { clearTimeout(timer) kernel.process.stdout?.off("data", handler) + kernel.process.off("exit", exitHandler) const json = buffer.slice(startIdx + startMarker.length, endIdx) try { resolve(JSON.parse(json)) @@ -336,33 +425,35 @@ export const NotebookTool = Tool.define("notebook", { metadata: {}, }) - const kernel = await getKernel(ctx.sessionID) - const result = await executeInKernel(kernel, params.code, timeout) + return serialize(ctx.sessionID, async () => { + const kernel = await getKernel(ctx.sessionID) + const result = await executeInKernel(kernel, params.code, timeout) - // Stream metadata updates for the UI - ctx.metadata({ - metadata: { - output: result.stdout || result.stderr || "(no output)", - ok: result.ok, - }, - }) + // Stream metadata updates for the UI + ctx.metadata({ + metadata: { + output: result.stdout || result.stderr || "(no output)", + ok: result.ok, + }, + }) - const parts: string[] = [] - if (result.stdout) parts.push(result.stdout) - if (result.stderr) { - parts.push(result.ok ? `[stderr]\n${result.stderr}` : `[ERROR]\n${result.stderr}`) - } - if (!parts.length) parts.push("(no output)") + const parts: string[] = [] + if (result.stdout) parts.push(result.stdout) + if (result.stderr) { + parts.push(result.ok ? `[stderr]\n${result.stderr}` : `[ERROR]\n${result.stderr}`) + } + if (!parts.length) parts.push("(no output)") - const output = parts.join("\n") + const output = parts.join("\n") - return { - title: result.ok ? "Python cell" : "Python cell (error)", - output, - metadata: { - ok: result.ok, - output: output.length > 30_000 ? output.slice(0, 30_000) + "\n\n..." : output, - }, - } + return { + title: result.ok ? "Python cell" : "Python cell (error)", + output, + metadata: { + ok: result.ok, + output: output.length > 30_000 ? output.slice(0, 30_000) + "\n\n..." : output, + }, + } + }) }, }) diff --git a/backend/cli/src/tool/edit.ts b/backend/cli/src/tool/edit.ts index 2ebee34e..d9c45eba 100644 --- a/backend/cli/src/tool/edit.ts +++ b/backend/cli/src/tool/edit.ts @@ -17,6 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" +import { SafeFileIO } from "@/file/safe-io" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -51,7 +52,10 @@ export const EditTool = Tool.define("edit", { let contentNew = "" await FileTime.withLock(filePath, async () => { if (params.oldString === "") { - const existed = await Bun.file(filePath).exists() + const approved = await SafeFileIO.optional(filePath) + const existed = !!approved + contentOld = approved?.bytes.toString("utf8") ?? "" + if (approved) await FileTime.assert(ctx.sessionID, filePath) contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await ctx.ask({ @@ -63,7 +67,7 @@ export const EditTool = Tool.define("edit", { diff, }, }) - await Bun.write(filePath, params.newString) + await SafeFileIO.write(filePath, params.newString, approved) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -75,12 +79,12 @@ export const EditTool = Tool.define("edit", { return } - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + const approved = await SafeFileIO.read(filePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`File ${filePath} not found`) + throw error + }) await FileTime.assert(ctx.sessionID, filePath) - contentOld = await file.text() + contentOld = approved.bytes.toString("utf8") contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff( @@ -96,7 +100,7 @@ export const EditTool = Tool.define("edit", { }, }) - await file.write(contentNew) + await SafeFileIO.write(filePath, contentNew, approved) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -104,7 +108,7 @@ export const EditTool = Tool.define("edit", { file: filePath, event: "change", }) - contentNew = await file.text() + contentNew = await Bun.file(filePath).text() diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) diff --git a/backend/cli/src/tool/modal.ts b/backend/cli/src/tool/modal.ts index b0871246..e0813f8d 100644 --- a/backend/cli/src/tool/modal.ts +++ b/backend/cli/src/tool/modal.ts @@ -43,7 +43,12 @@ export const ModalTool = Tool.define("modal", { .min(1) .max(24 * 60) .describe("Required job limit chosen from the expected runtime plus a reasonable safety margin."), - wait: z.boolean().default(true).describe("Wait for completion and return the job log; use false for long jobs."), + wait: z + .boolean() + .default(false) + .describe( + "Wait for completion and return the job log only when explicitly requested; dispatch returns immediately by default.", + ), }), async execute(input, ctx) { // Kept dynamic because ComputeSettings currently owns both route handlers and @@ -71,6 +76,7 @@ export const ModalTool = Tool.define("modal", { sessionID: ctx.sessionID, } const plan = await ComputeJobs.plan(request, { modal: config }) + if (plan.provider !== "modal") throw new Error("Modal approval returned a non-Modal plan") const metadata = { compute: { ...plan, name: input.name } } ctx.metadata({ title: `Review Modal job: ${input.name}`, metadata }) await ctx.ask({ diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index fd8f63de..cb1a9fbd 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -3,7 +3,7 @@ import { Tool } from "./tool" import { spawn, type ChildProcess } from "child_process" import path from "path" import os from "os" -import { mkdirSync, rmSync, unlinkSync } from "fs" +import { accessSync, constants, mkdirSync, rmSync, statSync, unlinkSync } from "fs" import { Shell } from "@/shell/shell" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" @@ -13,6 +13,7 @@ import { Sandbox } from "@/sandbox/sandbox" import { KernelQueue } from "@/science/kernel/queue" import { KernelProcessIdentity } from "@/science/kernel/process" import { KernelRuntime } from "@/science/kernel/registry" +import { KernelEnvironmentName, pythonEnvironment } from "@/science/kernel/interpreter" import { AtlasEnvironment } from "@/science/kernel/types" import type { Kernel, @@ -25,6 +26,7 @@ import type { KernelOutput, KernelProcess, } from "@/science/kernel/types" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" /** * General, non-domain-gated persistent Python kernel. @@ -90,7 +92,7 @@ def _load_science(code): _exec_count = 0 -_real_out.write("__OPENSCIENCE_KERNEL_READY__\\n") +_real_out.write("__OPENSCIENCE_KERNEL_READY__" + json.dumps({"version": "Python " + sys.version.split()[0]}) + "\\n") _real_out.flush() while True: @@ -198,13 +200,18 @@ interface RawPayload { execution_count: number } -async function findPython(override?: string): Promise { +async function findPython(override?: string): Promise<{ binary: string; version?: string }> { const candidates = override ? [override] : ["python3", "python"] for (const bin of candidates) { try { - const proc = Bun.spawn([bin, "--version"], { stdout: "pipe", stderr: "pipe" }) - await proc.exited - if (proc.exitCode === 0) return bin + // Resolution is metadata-only. A project `.venv/.../python` must never + // receive a preflight `--version` execution before KernelRuntime has + // acquired trust, authority, sandbox and durable process ownership. The + // governed kernel reports its version in the READY frame instead. + const binary = path.isAbsolute(bin) ? bin : Bun.which(bin) + if (!binary || !statSync(binary).isFile()) continue + accessSync(binary, process.platform === "win32" ? constants.F_OK : constants.X_OK) + return { binary } } catch {} } throw new Error("Python not found. Install Python 3.10+ (python3) to use the notebook tool.") @@ -283,18 +290,22 @@ class PythonKernel implements Kernel { this.configPath = configPath this.cachePath = cachePath - const bin = await findPython(opts?.binary) + const interpreter = await findPython(opts?.binary) const workspace = opts?.sessionID ? await SessionFilesystem.processWriteRoots(opts.sessionID) : [Instance.directory, Instance.worktree] + const readable = opts?.sessionID + ? await SessionFilesystem.processReadRoots(opts.sessionID) + : [Instance.directory, Instance.worktree] // Confine the kernel to the workspace when the execution sandbox is on: the // notebook runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must not be able to escape the boundary bash respects. const policy = await Config.trustedSandbox() const sandboxed = Sandbox.wrapArgv({ - file: bin, + file: interpreter.binary, args: ["-u", scriptPath], workspace, + readable, extraWritable: [scriptPath, configPath, cachePath], unreadable: OpenScience.kernelSensitivePaths(), options: policy, @@ -302,6 +313,11 @@ class PythonKernel implements Kernel { const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { cwd, + interpreter: { + name: opts?.environmentName ?? "python", + binary: interpreter.binary, + version: interpreter.version, + }, atlas: AtlasEnvironment, sandbox: { ...Sandbox.describe(), @@ -312,26 +328,46 @@ class PythonKernel implements Kernel { warning: sandboxed.warning, }, } - const proc = spawn(sandboxed.file, sandboxed.args, { - cwd, - env: { - ...OpenScience.kernelEnv(process.env), - ...OpenScience.pythonThreadCapEnv(process.env), - ...(opts?.env ?? {}), - ATLAS_CLI_CONFIG_PATH: configPath, - MPLCONFIGDIR: path.join(cachePath, "matplotlib"), - XDG_CACHE_HOME: path.join(cachePath, "xdg"), - PYTHONPYCACHEPREFIX: path.join(cachePath, "pycache"), - PYTHONUNBUFFERED: "1", - }, - stdio: ["pipe", "pipe", "pipe"], - // Own process group so killing the kernel reaps its children too — a scanpy - // run forks joblib/BLAS workers that would otherwise be orphaned and keep - // thrashing swap after an abort (#102). - detached: process.platform !== "win32", - }) + const wrapped = WindowsJobLauncher.wrap({ file: sandboxed.file, args: sandboxed.args }) + let proc: ChildProcess + try { + proc = spawn(wrapped.file, wrapped.args, { + cwd, + env: { + ...OpenScience.kernelEnv(process.env), + ...OpenScience.pythonThreadCapEnv(process.env), + ...(opts?.env ?? {}), + ATLAS_CLI_CONFIG_PATH: configPath, + MPLCONFIGDIR: path.join(cachePath, "matplotlib"), + XDG_CACHE_HOME: path.join(cachePath, "xdg"), + PYTHONPYCACHEPREFIX: path.join(cachePath, "pycache"), + PYTHONUNBUFFERED: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + // Own process group so killing the kernel reaps its children too — a scanpy + // run forks joblib/BLAS workers that would otherwise be orphaned and keep + // thrashing swap after an abort (#102). + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandboxed) + throw error + } + proc.once("exit", () => Sandbox.cleanup(sandboxed)) + proc.once("error", () => Sandbox.cleanup(sandboxed)) this.proc = proc this.process = KernelProcessIdentity.capture(proc) + try { + const ownership = opts?.processOwnership + ? { ...opts.processOwnership, windowsRelease: wrapped.release } + : undefined + const registered = await KernelProcessIdentity.register(proc, ownership) + if (!registered) throw new Error("Python kernel exited before durable process registration") + this.process = registered + } catch (error) { + await this.terminate(proc) + throw error + } proc.once("exit", () => { if (!this.intentional) this.cleanupScript() }) @@ -349,7 +385,31 @@ class PythonKernel implements Kernel { let buf = "" const onData = (d: Buffer) => { buf += d.toString() - if (buf.includes(READY)) { + if (buf.length > 64 * 1024) { + clearTimeout(timer) + proc.stdout?.off("data", onData) + void this.terminate(proc) + reject(new Error("Python kernel startup output exceeded 65536 bytes before the ready handshake")) + return + } + const start = buf.indexOf(READY) + const end = start === -1 ? -1 : buf.indexOf("\n", start) + if (start !== -1 && end !== -1) { + const frame = buf.slice(start + READY.length, end) + if (frame) { + try { + const ready = JSON.parse(frame) as { version?: unknown } + if (typeof ready.version === "string" && ready.version.length <= 128) { + this.environment!.interpreter.version = ready.version + } + } catch { + clearTimeout(timer) + proc.stdout?.off("data", onData) + void this.terminate(proc) + reject(new Error("Python kernel returned an invalid ready handshake")) + return + } + } clearTimeout(timer) proc.stdout?.off("data", onData) resolve() @@ -577,12 +637,13 @@ function clip(s: string, max = 30_000): string { export const NotebookTool = Tool.define("notebook", { description: [ - "Execute Python code in a persistent, managed kernel. Variables, imports, and state persist across calls that use the same kernel name.", + "Execute Python code in a persistent, managed kernel. Variables, imports, and state persist across calls that use the same kernel name and environment.", + "Choose `environment` to address a project interpreter under .venv/; the default python environment also discovers a conventional .venv.", "For multiple independent analyses, issue multiple notebook calls in the same response with distinct `kernel` names. Those kernels execute concurrently and appear separately in Compute.", "Always set `title` to a concise description of the scientific action, not a code fragment or import.", "Set `source` when the cell belongs to a script or .ipynb file so Compute can identify that source.", "Never use shell subprocesses to imitate multiple kernels; use this tool's `kernel` parameter instead.", - "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel name so completed workers do not idle.", + "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel and environment so completed workers do not idle.", "Use instead of `bash python` for analysis — no need to re-import or re-load data between cells.", "numpy (np), pandas (pd), scipy, and matplotlib (plt) are pre-imported. Expression results auto-display like Jupyter.", "matplotlib figures are captured as inline PNG images. Not gated to any agent.", @@ -613,8 +674,12 @@ export const NotebookTool = Tool.define("notebook", { .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) .optional() .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), + environment: KernelEnvironmentName.optional().describe( + "Project Python environment: .venv/, with .venv itself also used for the default python environment.", + ), timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), }) + .strict() .superRefine((params, issue) => { if (params.action !== "stop" && !params.code) { issue.addIssue({ code: "custom", path: ["code"], message: "code is required when action is execute" }) @@ -622,20 +687,26 @@ export const NotebookTool = Tool.define("notebook", { }), async execute(params, ctx) { const name = params.kernel ?? "agent" + const environment = params.environment ?? "python" const identity = { projectID: Instance.project.id, sessionID: ctx.sessionID, name, language: "python" as const, + environmentName: environment === "python" ? undefined : environment, } if (params.action === "stop") { - ctx.metadata({ title: `Stopped Python · ${name}`, metadata: { kernel: name, language: "python", stopped: true } }) + ctx.metadata({ + title: `Stopped Python · ${name}`, + metadata: { kernel: name, environment, language: "python", stopped: true }, + }) await KernelRuntime.release(identity) return { title: `Stopped Python · ${name}`, output: `Managed kernel ${name} stopped. Its in-memory state was cleared.`, metadata: { kernel: name, + environment, language: "python", stopped: true, ok: true, @@ -646,7 +717,13 @@ export const NotebookTool = Tool.define("notebook", { const title = params.title ?? "Python cell" ctx.metadata({ title, - metadata: { kernel: name, language: "python", task: title, ...(params.source ? { source: params.source } : {}) }, + metadata: { + kernel: name, + environment, + language: "python", + task: title, + ...(params.source ? { source: params.source } : {}), + }, }) // Executes arbitrary code — same permission gate as bash. @@ -657,11 +734,17 @@ export const NotebookTool = Tool.define("notebook", { metadata: {}, }) - const result = await KernelRuntime.execute(identity, params.code!, { - timeout: params.timeout, - signal: ctx.abort, - origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, - }) + const runtime = await pythonEnvironment(Instance.directory, environment) + const result = await KernelRuntime.execute( + identity, + params.code!, + { + timeout: params.timeout, + signal: ctx.abort, + origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, + }, + runtime, + ) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) const dataUrls = images.map((o) => `data:image/png;base64,${o.data!["image/png"]}`) @@ -687,6 +770,7 @@ export const NotebookTool = Tool.define("notebook", { ok: result.ok, provenanceID: result.provenanceID, kernel: name, + environment, language: "python", task: title, ...(params.source ? { source: params.source } : {}), @@ -701,6 +785,7 @@ export const NotebookTool = Tool.define("notebook", { ok: result.ok, output, kernel: name, + environment, language: "python", task: title, ...(params.source ? { source: params.source } : {}), diff --git a/backend/cli/src/tool/read.ts b/backend/cli/src/tool/read.ts index 0b56a47f..29198cfa 100644 --- a/backend/cli/src/tool/read.ts +++ b/backend/cli/src/tool/read.ts @@ -10,6 +10,7 @@ import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { readImageDimensions } from "../util/image" +import { SafeFileIO } from "@/file/safe-io" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -47,8 +48,8 @@ export const ReadTool = Tool.define("read", { metadata: {}, }) - const file = Bun.file(filepath) - if (!(await file.exists())) { + const snapshot = await SafeFileIO.optional(filepath) + if (!snapshot) { const dir = path.dirname(filepath) const base = path.basename(filepath) @@ -67,6 +68,7 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } + const file = Bun.file(filepath) const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID) @@ -76,10 +78,9 @@ export const ReadTool = Tool.define("read", { const isPdf = file.type === "application/pdf" if (isImage || isPdf) { const kind = isImage ? "Image" : "PDF" - const attachStat = await file.stat() - if (attachStat.size > MAX_ATTACHMENT_BYTES) { + if (snapshot.bytes.byteLength > MAX_ATTACHMENT_BYTES) { throw new Error( - `${kind} too large to attach (${attachStat.size} bytes > ${MAX_ATTACHMENT_BYTES}). ` + + `${kind} too large to attach (${snapshot.bytes.byteLength} bytes > ${MAX_ATTACHMENT_BYTES}). ` + `Anthropic's API caps base64 attachments at ~32 MB. ` + (isPdf ? "Use the liteparse skill to extract text via the `lit` CLI instead " + @@ -88,7 +89,7 @@ export const ReadTool = Tool.define("read", { ) } const mime = file.type - const fileBytes = await file.bytes() + const fileBytes = snapshot.bytes if (isImage) { const dims = readImageDimensions(fileBytes) if (dims && Math.max(dims.width, dims.height) > MAX_IMAGE_DIMENSION) { @@ -122,12 +123,12 @@ export const ReadTool = Tool.define("read", { } } - const isBinary = await isBinaryFile(filepath, file) + const isBinary = isBinaryFile(filepath, snapshot.bytes) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const lines = await file.text().then((text) => text.split("\n")) + const lines = snapshot.bytes.toString("utf8").split("\n") const raw: string[] = [] let bytes = 0 @@ -185,7 +186,7 @@ export const ReadTool = Tool.define("read", { }, }) -async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { +function isBinaryFile(filepath: string, buffer: Uint8Array): boolean { const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { @@ -222,16 +223,11 @@ async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { + const compute = async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("{tool,tools}/*.{js,ts}") - for (const dir of await Config.directories()) { + // Importing a tool module executes its top-level code in the host process. + // Config.executableDirectories excludes project-owned directories until + // their canonical project root has been explicitly trusted. + for (const dir of await Config.executableDirectories()) { for await (const match of glob.scan({ cwd: dir, absolute: true, @@ -56,28 +62,50 @@ export namespace ToolRegistry { dot: true, })) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) + // A symlinked file is still project-owned when its directory entry is + // project-owned. Serialize the final trust check and module import with + // revocation so top-level module code cannot finish after a revoke has + // already been acknowledged. + const projectOwned = Instance.containsPath(dir) + const mod = projectOwned + ? await AuthoritySignal.exclusive(async () => { + await ProjectTrust.require(Instance.project, "project_plugin") + return import(match) + }) + : await import(match) for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def, projectOwned)) } } } const plugins = await Plugin.list() for (const plugin of plugins) { + const projectOwned = Plugin.projectOwned(plugin) for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) + custom.push(fromPlugin(id, def, projectOwned)) } } return { custom } - }) + } + + export const state = Instance.state(compute) + + /** Evict imported project tools and plugin tools after a trust transition. */ + export function invalidate() { + State.clear(Instance.directory, compute) + } - function fromPlugin(id: string, def: ToolDefinition): Tool.Info { + function fromPlugin(id: string, def: ToolDefinition, projectOwned = false): Tool.Info { return Tool.define(id, async (initCtx) => ({ parameters: z.object(def.args), description: def.description, execute: async (args, ctx) => { + // Cache eviction removes the tool from future registries. This check is + // the fail-closed guard for a caller that retained an initialized tool + // object across revocation. + if (projectOwned) await ProjectTrust.require(Instance.project, "project_plugin") const pluginCtx = { ...ctx, directory: Instance.directory, diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index bf845640..1fb47e7a 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -3,7 +3,7 @@ import { Tool } from "./tool" import { spawn, type ChildProcess } from "child_process" import path from "path" import os from "os" -import { unlinkSync } from "fs" +import { accessSync, constants, statSync, unlinkSync } from "fs" import { Shell } from "@/shell/shell" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" @@ -25,6 +25,7 @@ import type { KernelOutput, KernelProcess, } from "@/science/kernel/types" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" /** * Persistent R kernel, following the same pattern as the Python kernel in @@ -113,7 +114,7 @@ run_cell <- function(code) { con <- file("stdin") open(con, blocking = TRUE) -cat("__OPENSCIENCE_KERNEL_READY__\\n") +cat("__OPENSCIENCE_KERNEL_READY__", R.version.string, "\\n", sep = "") flush(stdout()) repeat { @@ -139,13 +140,18 @@ const START = "__OPENSCIENCE_R_RESULT_START__\n" const END = "\n__OPENSCIENCE_R_END__" const IDLE_MS = 30 * 60 * 1000 -async function findRscript(override?: string): Promise { +async function findRscript(override?: string): Promise<{ binary: string; version?: string } | null> { const candidates = override ? [override] : ["Rscript"] for (const bin of candidates) { try { - const proc = Bun.spawn([bin, "--version"], { stdout: "pipe", stderr: "pipe" }) - await proc.exited - if (proc.exitCode === 0) return bin + // Discovery is metadata-only. A project-selected runtime must not + // receive a preflight `--version` execution before KernelRuntime has + // acquired trust, sandbox authority and durable OS ownership. The + // registered interpreter reports its version in the READY frame. + const binary = path.isAbsolute(bin) ? bin : Bun.which(bin) + if (!binary || !statSync(binary).isFile()) continue + accessSync(binary, process.platform === "win32" ? constants.F_OK : constants.X_OK) + return { binary } } catch {} } return null @@ -237,8 +243,8 @@ class RKernel implements Kernel { if (this.ready) return this.intentional = false this.stderrTail = "" - const bin = await findRscript(opts?.binary) - if (!bin) { + const interpreter = await findRscript(opts?.binary) + if (!interpreter) { throw new Error( "Rscript not found. Install R (https://www.r-project.org) so `Rscript` is on PATH to use the R kernel.", ) @@ -253,15 +259,19 @@ class RKernel implements Kernel { const workspace = opts?.sessionID ? await SessionFilesystem.processWriteRoots(opts.sessionID) : [Instance.directory, Instance.worktree] + const readable = opts?.sessionID + ? await SessionFilesystem.processReadRoots(opts.sessionID) + : [Instance.directory, Instance.worktree] // Confine the kernel to the workspace when the execution sandbox is on: the R // kernel runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must respect the same boundary. const policy = await Config.trustedSandbox() const sandboxed = Sandbox.wrapArgv({ - file: bin, + file: interpreter.binary, args: ["--vanilla", scriptPath], workspace, + readable, extraWritable: [scriptPath, configPath], unreadable: OpenScience.kernelSensitivePaths(), options: policy, @@ -269,6 +279,11 @@ class RKernel implements Kernel { const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { cwd, + interpreter: { + name: opts?.environmentName ?? "r", + binary: interpreter.binary, + version: interpreter.version, + }, atlas: AtlasEnvironment, sandbox: { ...Sandbox.describe(), @@ -279,19 +294,39 @@ class RKernel implements Kernel { warning: sandboxed.warning, }, } - const proc = spawn(sandboxed.file, sandboxed.args, { - cwd, - env: { - ...OpenScience.kernelEnv(process.env), - ...(opts?.env ?? {}), - ATLAS_CLI_CONFIG_PATH: configPath, - }, - stdio: ["pipe", "pipe", "pipe"], - // Own process group so killing the kernel reaps its worker children (#102). - detached: process.platform !== "win32", - }) + const wrapped = WindowsJobLauncher.wrap({ file: sandboxed.file, args: sandboxed.args }) + let proc: ChildProcess + try { + proc = spawn(wrapped.file, wrapped.args, { + cwd, + env: { + ...OpenScience.kernelEnv(process.env), + ...(opts?.env ?? {}), + ATLAS_CLI_CONFIG_PATH: configPath, + }, + stdio: ["pipe", "pipe", "pipe"], + // Own process group so killing the kernel reaps its worker children (#102). + detached: process.platform !== "win32", + }) + } catch (error) { + Sandbox.cleanup(sandboxed) + throw error + } + proc.once("exit", () => Sandbox.cleanup(sandboxed)) + proc.once("error", () => Sandbox.cleanup(sandboxed)) this.proc = proc this.process = KernelProcessIdentity.capture(proc) + try { + const ownership = opts?.processOwnership + ? { ...opts.processOwnership, windowsRelease: wrapped.release } + : undefined + const registered = await KernelProcessIdentity.register(proc, ownership) + if (!registered) throw new Error("R kernel exited before durable process registration") + this.process = registered + } catch (error) { + await this.terminate(proc) + throw error + } proc.once("exit", () => { if (!this.intentional) this.cleanupScript() }) @@ -309,7 +344,25 @@ class RKernel implements Kernel { let buf = "" const onData = (d: Buffer) => { buf += d.toString() - if (buf.includes(READY)) { + if (buf.length > 64 * 1024) { + clearTimeout(timer) + proc.stdout?.off("data", onData) + void this.terminate(proc) + reject(new Error("R kernel startup output exceeded 65536 bytes before the ready handshake")) + return + } + const start = buf.indexOf(READY) + const end = start === -1 ? -1 : buf.indexOf("\n", start) + if (start !== -1 && end !== -1) { + const version = buf.slice(start + READY.length, end).trim() + if (!version || version.length > 128 || /[\0\r]/.test(version)) { + clearTimeout(timer) + proc.stdout?.off("data", onData) + void this.terminate(proc) + reject(new Error("R kernel returned an invalid ready handshake")) + return + } + this.environment!.interpreter.version = version clearTimeout(timer) proc.stdout?.off("data", onData) resolve() @@ -556,6 +609,7 @@ export const RKernelTool = Tool.define("rkernel", { .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), }) + .strict() .superRefine((params, issue) => { if (params.action !== "stop" && !params.code) { issue.addIssue({ code: "custom", path: ["code"], message: "code is required when action is execute" }) diff --git a/backend/cli/src/tool/webfetch.ts b/backend/cli/src/tool/webfetch.ts index 10a92261..f14c9f6a 100644 --- a/backend/cli/src/tool/webfetch.ts +++ b/backend/cli/src/tool/webfetch.ts @@ -27,17 +27,23 @@ export const WebFetchTool = Tool.define("webfetch", { // Answering "always" adds the domain to the persisted allow-list (visible // in Network settings); conversation/project scopes approve quietly on // later requests without widening the stored list. - const host = await Network.blocked(params.url) - if (host) { + const approvedHosts = new Set() + const authorize = async (input: { host: string; url: string }) => { + if (approvedHosts.has(input.host)) return await ctx.ask({ permission: "network", - patterns: [host], - always: [host], + patterns: [input.host], + always: [input.host], metadata: { - url: params.url, - network: { host }, + url: input.url, + network: { host: input.host }, }, }) + approvedHosts.add(input.host) + } + const host = await Network.blocked(params.url) + if (host) { + await authorize({ host, url: params.url }) } // Scope an "always" style grant to this site, never the whole tool. @@ -89,81 +95,87 @@ export const WebFetchTool = Tool.define("webfetch", { "Accept-Language": "en-US,en;q=0.9", } - const initial = await fetch(params.url, { signal, headers }) - - // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) - const response = - initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" - ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "openscience" } }) - : initial - - clearTimeout(timeoutId) - - if (!response.ok) { - throw new Error(`Request failed with status code: ${response.status}`) - } - - // Check content length - const contentLength = response.headers.get("content-length") - if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } - - const arrayBuffer = await response.arrayBuffer() - if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } + try { + const initial = await Network.fetch(params.url, { signal, headers }, { authorize }) + + // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) + const response = + initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" + ? await Network.fetch( + params.url, + { signal, headers: { ...headers, "User-Agent": "openscience" } }, + { authorize }, + ) + : initial + + if (!response.ok) { + throw new Error(`Request failed with status code: ${response.status}`) + } - const content = new TextDecoder().decode(arrayBuffer) - const contentType = response.headers.get("content-type") || "" + // Check content length + const contentLength = response.headers.get("content-length") + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } - const title = `${params.url} (${contentType})` + const arrayBuffer = await response.arrayBuffer() + if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } - // Handle content based on requested format and actual content type - switch (params.format) { - case "markdown": - if (contentType.includes("text/html")) { - const markdown = convertHTMLToMarkdown(content) + const content = new TextDecoder().decode(arrayBuffer) + const contentType = response.headers.get("content-type") || "" + + const title = `${params.url} (${contentType})` + + // Handle content based on requested format and actual content type + switch (params.format) { + case "markdown": + if (contentType.includes("text/html")) { + const markdown = convertHTMLToMarkdown(content) + return { + output: markdown, + title, + metadata: {}, + } + } return { - output: markdown, + output: content, title, metadata: {}, } - } - return { - output: content, - title, - metadata: {}, - } - case "text": - if (contentType.includes("text/html")) { - const text = await extractTextFromHTML(content) + case "text": + if (contentType.includes("text/html")) { + const text = await extractTextFromHTML(content) + return { + output: text, + title, + metadata: {}, + } + } return { - output: text, + output: content, title, metadata: {}, } - } - return { - output: content, - title, - metadata: {}, - } - case "html": - return { - output: content, - title, - metadata: {}, - } + case "html": + return { + output: content, + title, + metadata: {}, + } - default: - return { - output: content, - title, - metadata: {}, - } + default: + return { + output: content, + title, + metadata: {}, + } + } + } finally { + clearTimeout(timeoutId) } }, }) diff --git a/backend/cli/src/tool/write.ts b/backend/cli/src/tool/write.ts index 0bfde8c0..23e110e1 100644 --- a/backend/cli/src/tool/write.ts +++ b/backend/cli/src/tool/write.ts @@ -12,6 +12,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectory } from "./external-directory" +import { SafeFileIO } from "@/file/safe-io" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -28,9 +29,9 @@ export const WriteTool = Tool.define("write", { : path.join(Instance.directory, params.filePath) const filepath = (await assertExternalDirectory(ctx, requested, { access: "write" }))?.path ?? requested - const file = Bun.file(filepath) - const exists = await file.exists() - const contentOld = exists ? await file.text() : "" + const approved = await SafeFileIO.optional(filepath) + const exists = !!approved + const contentOld = approved?.bytes.toString("utf8") ?? "" if (exists) await FileTime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) @@ -44,7 +45,7 @@ export const WriteTool = Tool.define("write", { }, }) - await Bun.write(filepath, params.content) + await SafeFileIO.write(filepath, params.content, approved) await Bus.publish(File.Event.Edited, { file: filepath, }) diff --git a/backend/cli/src/util/file-lease.ts b/backend/cli/src/util/file-lease.ts new file mode 100644 index 00000000..24865653 --- /dev/null +++ b/backend/cli/src/util/file-lease.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { DataRootBarrier } from "@/global/data-root-barrier" + +export namespace FileLease { + const timeout = 10_000 + const grace = 5_000 + + type Owner = { + pid: number + token: string + created: number + } + + function running(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } + } + + async function owner(filepath: string) { + return Bun.file(filepath) + .json() + .catch(() => undefined) + } + + function exactOwner(value: unknown): value is Owner { + return ( + !!value && + typeof value === "object" && + "pid" in value && + typeof value.pid === "number" && + "token" in value && + typeof value.token === "string" && + "created" in value && + typeof value.created === "number" + ) + } + + async function abandoned(filepath: string, value: unknown) { + const owner = value + if (owner && typeof owner === "object" && "pid" in owner && typeof owner.pid === "number") { + return !running(owner.pid) + } + const stat = await fs.stat(filepath).catch(() => undefined) + return !!stat && Date.now() - stat.mtimeMs > grace + } + + export async function acquire(filepath: string, timeoutMs = timeout): Promise { + const operation = await DataRootBarrier.enter(filepath, timeoutMs) + try { + let blockedAt = Date.now() + let blockedOwner: string | undefined + const token = crypto.randomUUID() + const parent = path.dirname(filepath) + await fs.mkdir(parent, { recursive: true }) + // Pin the lock to the physical directory selected while the operation + // marker is live. If the managed data-root link changes later, disposal + // must remove the source lock it actually acquired rather than following + // the new link and leaking a permanently-live lock in the old root. + filepath = path.join(await fs.realpath(parent), path.basename(filepath)) + + const open = async (): Promise>> => { + const handle = await fs.open(filepath, "wx", 0o600).catch(async (error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error + const current = await owner(filepath) + if (await abandoned(filepath, current)) { + const aside = `${filepath}.${crypto.randomUUID()}.dead` + const claimed = await fs + .rename(filepath, aside) + .then(() => true) + .catch(() => false) + if (claimed) await fs.rm(aside, { force: true }) + if (claimed) return open() + } + // Timeout one unchanged owner, not the whole healthy queue. Each + // lease writes a unique token, so an exact owner change proves that + // the serialized operation ahead of us completed and the queue made + // progress. A live but wedged owner still fails within timeoutMs. + if (exactOwner(current)) { + const signature = `${current.pid}\0${current.token}\0${current.created}` + if (signature !== blockedOwner) { + blockedOwner = signature + blockedAt = Date.now() + } + } + if (Date.now() - blockedAt >= timeoutMs) { + throw new Error(`Timed out waiting for another OpenScience process to release ${filepath}`) + } + await Bun.sleep(15) + return open() + }) + return handle + } + + const handle = await open() + await handle + .writeFile(JSON.stringify({ pid: process.pid, token, created: Date.now() })) + .then(() => handle.sync()) + .catch(async (error) => { + await handle.close().catch(() => undefined) + await fs.rm(filepath, { force: true }).catch(() => undefined) + throw error + }) + return { + async [Symbol.asyncDispose]() { + await handle.close().catch(() => undefined) + const owner = await Bun.file(filepath) + .json() + .catch(() => undefined) + if (owner && typeof owner === "object" && "token" in owner && owner.token === token) { + await fs.rm(filepath, { force: true }).catch(() => undefined) + } + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + }, + } + } catch (error) { + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + throw error + } + } +} diff --git a/backend/cli/src/util/jsonstore.ts b/backend/cli/src/util/jsonstore.ts index d3bf7526..2bf512d7 100644 --- a/backend/cli/src/util/jsonstore.ts +++ b/backend/cli/src/util/jsonstore.ts @@ -1,6 +1,7 @@ import fs from "fs/promises" import path from "path" import { Lock } from "./lock" +import { DataRootBarrier } from "@/global/data-root-barrier" /** * Shared persistence for small JSON-object credential stores (auth.json, @@ -128,6 +129,7 @@ export namespace JsonStore { filepath: string, fn: (data: Record) => Record | void | Promise | void>, ): Promise { + await using operation = await DataRootBarrier.enter(filepath) using _ = await Lock.write(filepath) await using file = await fileLock(filepath) const data = await load(filepath) diff --git a/backend/cli/src/util/log.ts b/backend/cli/src/util/log.ts index 6941310b..580c2c44 100644 --- a/backend/cli/src/util/log.ts +++ b/backend/cli/src/util/log.ts @@ -1,6 +1,7 @@ import path from "path" import fs from "fs/promises" import { Global } from "../global" +import { DataRootBarrier } from "../global/data-root-barrier" import z from "zod" export namespace Log { @@ -54,6 +55,11 @@ export namespace Log { process.stderr.write(msg) return msg.length } + let pending = Promise.resolve() + + export function flush() { + return pending + } export async function init(options: Options) { if (options.level) level = options.level @@ -63,13 +69,23 @@ export namespace Log { Global.Path.log, options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", ) - const logfile = Bun.file(logpath) await fs.truncate(logpath).catch(() => {}) - const writer = logfile.writer() - write = async (msg: any) => { - const num = writer.write(msg) - writer.flush() - return num + write = (msg: any) => { + const content = String(msg) + // Resolve the stable data-root link for every serialized append instead + // of retaining an fd into one physical root. Relocation intent blocks a + // new append, drains earlier ones, snapshots the logs, switches the link, + // then releases the same precomputed path onto the new target. + pending = pending + .catch(() => undefined) + .then(async () => { + await using operation = await DataRootBarrier.enter(logpath, 120_000) + await fs.appendFile(logpath, content) + }) + .catch((error) => { + process.stderr.write(`OpenScience log write failed: ${String(error)}\n`) + }) + return content.length } } diff --git a/backend/cli/test/agent/agent.test.ts b/backend/cli/test/agent/agent.test.ts index ff8cbcca..a2f4cdf8 100644 --- a/backend/cli/test/agent/agent.test.ts +++ b/backend/cli/test/agent/agent.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, trustProject } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { PermissionNext } from "../../src/permission/next" @@ -115,6 +115,36 @@ test("compaction agent denies all permissions", async () => { }) }) +test("untrusted project agent configuration stays inert", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "repo-agent", + permission: { bash: "deny" }, + agent: { + "repo-agent": { + mode: "primary", + prompt: "repository-controlled", + }, + research: { + prompt: "repository-controlled", + color: "#FF0000", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Agent.get("repo-agent")).toBeUndefined() + const research = await Agent.get("research") + expect(research?.prompt).toBeUndefined() + expect(research?.color).toBe("#06b6d4") + expect(evalPerm(research, "bash")).toBe("allow") + expect(await Agent.defaultAgent()).toBe("research") + }, + }) +}) + test("custom agent from config creates new agent", async () => { await using tmp = await tmpdir({ config: { @@ -131,6 +161,7 @@ test("custom agent from config creates new agent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const custom = await Agent.get("my_custom_agent") expect(custom).toBeDefined() expect(custom?.model?.providerID).toBe("openai") @@ -158,6 +189,7 @@ test("legacy docs config remains a subagent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const docs = await Agent.get("docs") expect(docs?.mode).toBe("subagent") expect(docs?.description).toBe("Documentation specialist") @@ -181,6 +213,7 @@ test("custom agent config overrides native agent properties", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research).toBeDefined() expect(research?.model?.providerID).toBe("anthropic") @@ -204,6 +237,7 @@ test("agent disable removes agent from list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const explore = await Agent.get("explore") expect(explore).toBeUndefined() const agents = await Agent.list() @@ -230,6 +264,7 @@ test("agent permission config merges with defaults", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research).toBeDefined() // Specific pattern is denied @@ -251,6 +286,7 @@ test("global permission config applies to all agents", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research).toBeDefined() expect(evalPerm(research, "bash")).toBe("deny") @@ -270,6 +306,7 @@ test("agent steps/maxSteps config sets steps property", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") const plan = await Agent.get("plan") expect(research?.steps).toBe(50) @@ -289,6 +326,7 @@ test("agent mode can be overridden", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const explore = await Agent.get("explore") expect(explore?.mode).toBe("primary") }, @@ -306,6 +344,7 @@ test("agent name can be overridden", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research?.name).toBe("Builder") }, @@ -323,6 +362,7 @@ test("agent prompt can be set from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research?.prompt).toBe("Custom system prompt") }, @@ -343,6 +383,7 @@ test("unknown agent properties are placed into options", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research?.options.random_property).toBe("hello") expect(research?.options.another_random).toBe(123) @@ -366,6 +407,7 @@ test("agent options merge correctly", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(research?.options.custom_option).toBe(true) expect(research?.options.another_option).toBe("value") @@ -391,6 +433,7 @@ test("multiple custom agents can be defined", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const agentA = await Agent.get("agent_a") const agentB = await Agent.get("agent_b") expect(agentA?.description).toBe("Agent A") @@ -451,6 +494,7 @@ test("legacy tools config converts to permissions", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(evalPerm(research, "bash")).toBe("deny") expect(evalPerm(research, "read")).toBe("deny") @@ -473,6 +517,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(evalPerm(research, "edit")).toBe("deny") }, @@ -491,6 +536,7 @@ test("Truncate.DIR is allowed even when user denies external_directory globally" await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(PermissionNext.evaluate("external_directory", Truncate.DIR, research!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, research!.permission).action).toBe("allow") @@ -517,6 +563,7 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(PermissionNext.evaluate("external_directory", Truncate.DIR, research!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, research!.permission).action).toBe("allow") @@ -542,6 +589,7 @@ test("explicit Truncate.DIR deny is respected", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const research = await Agent.get("research") expect(PermissionNext.evaluate("external_directory", Truncate.DIR, research!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, research!.permission).action).toBe("deny") @@ -569,6 +617,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const agent = await Agent.defaultAgent() expect(agent).toBe("plan") }, @@ -589,6 +638,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const agent = await Agent.defaultAgent() expect(agent).toBe("my_custom") }, @@ -604,6 +654,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent') }, }) @@ -618,6 +669,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden') }, }) @@ -632,6 +684,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found') }, }) @@ -648,6 +701,7 @@ test("defaultAgent does not silently replace disabled research with plan mode", await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found") }, }) @@ -668,6 +722,7 @@ test("defaultAgent throws when all primary visible agents are disabled", async ( await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found") }, }) diff --git a/backend/cli/test/artifact/store-multiprocess.test.ts b/backend/cli/test/artifact/store-multiprocess.test.ts new file mode 100644 index 00000000..111635cf --- /dev/null +++ b/backend/cli/test/artifact/store-multiprocess.test.ts @@ -0,0 +1,162 @@ +import { Database } from "bun:sqlite" +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +function environment(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: root, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state"), + } +} + +async function result(proc: { + exited: Promise + stdout: ReadableStream + stderr: ReadableStream +}) { + return { + exit: await proc.exited, + output: await new Response(proc.stdout).text(), + error: await new Response(proc.stderr).text(), + } +} + +test("independent processes atomically publish one blob and serialize versions", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-artifact-race-")) + const runner = path.join(root, "save.ts") + const store = new URL("../../src/artifact/store.ts", import.meta.url).href + const content = "shared immutable artifact bytes" + const total = 8 + await Bun.write( + runner, + ` +import { ArtifactStore } from ${JSON.stringify(store)} +const saved = await ArtifactStore.save({ + projectID: "project-race", + sessionID: "session-race", + sourcePath: "results/shared.txt", + filename: "shared.txt", + kind: "data", + content: new Blob([${JSON.stringify(content)}]), + captureQuality: "exact", +}) +console.log(JSON.stringify({ artifactID: saved.id, versionID: saved.currentVersionID })) +`, + ) + + try { + const processes = Array.from({ length: total }, () => + Bun.spawn([process.execPath, runner], { + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }), + ) + const results = await Promise.all(processes.map(result)) + expect(results.filter((result) => result.exit !== 0)).toEqual([]) + const saved = results.map((result) => JSON.parse(result.output.trim()) as Record) + expect(new Set(saved.map((item) => item.artifactID)).size).toBe(1) + expect(new Set(saved.map((item) => item.versionID)).size).toBe(total) + + const database = path.join(root, "artifact-store", "artifacts.db") + const db = new Database(database, { readonly: true }) + const versions = db.query("SELECT version, sha256 FROM versions ORDER BY version").all() as Array<{ + version: number + sha256: string + }> + const records = db.query("SELECT sha256, size, path FROM blobs").all() as Array<{ + sha256: string + size: number + path: string + }> + db.close() + expect(versions.map((item) => item.version)).toEqual(Array.from({ length: total }, (_, index) => index + 1)) + expect(new Set(versions.map((item) => item.sha256)).size).toBe(1) + expect(records).toHaveLength(1) + expect(records[0]?.sha256).toBe(new Bun.CryptoHasher("sha256").update(content).digest("hex")) + expect(records[0]?.size).toBe(Buffer.byteLength(content)) + const blob = path.join(root, "artifact-store", records[0]!.path) + expect(await Bun.file(blob).text()).toBe(content) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("a last-reference sweep cannot delete a concurrently re-saved blob", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-artifact-sweep-race-")) + const runner = path.join(root, "race.ts") + const store = new URL("../../src/artifact/store.ts", import.meta.url).href + const content = "bytes that must survive sweep" + await Bun.write( + runner, + ` +import { ArtifactStore } from ${JSON.stringify(store)} +const input = { + projectID: "project-race", + sessionID: "session-race", + sourcePath: "results/sweep.txt", + filename: "sweep.txt", + kind: "data", + content: new Blob([${JSON.stringify(content)}]), + captureQuality: "exact", +} +if (process.argv[2] === "seed") { + const saved = await ArtifactStore.save(input) + await ArtifactStore.trash(input.projectID, saved.id, 1) + console.log(JSON.stringify(saved)) +} +if (process.argv[2] === "save") console.log(JSON.stringify(await ArtifactStore.save(input))) +if (process.argv[2] === "sweep") console.log(JSON.stringify({ swept: await ArtifactStore.sweep(Date.now()) })) +`, + ) + + try { + const seed = await result( + Bun.spawn([process.execPath, runner, "seed"], { + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }), + ) + expect(seed.exit, seed.error).toBe(0) + + const [saved, swept] = await Promise.all( + ["save", "sweep"].map((mode) => + result( + Bun.spawn([process.execPath, runner, mode], { + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }), + ), + ), + ) + expect([saved, swept].filter((item) => item?.exit !== 0)).toEqual([]) + + const database = path.join(root, "artifact-store", "artifacts.db") + const db = new Database(database, { readonly: true }) + const record = db + .query( + `SELECT a.id, a.state, v.sha256, b.path + FROM artifacts a + JOIN versions v ON v.id = a.current_version_id + JOIN blobs b ON b.sha256 = v.sha256 + WHERE a.project_id = 'project-race' AND a.source_key = 'results/sweep.txt'`, + ) + .get() as { id: string; state: string; sha256: string; path: string } | null + db.close() + expect(record?.state).toBe("active") + expect(record?.sha256).toBe(new Bun.CryptoHasher("sha256").update(content).digest("hex")) + expect(await Bun.file(path.join(root, "artifact-store", record!.path)).text()).toBe(content) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/auth/auth.test.ts b/backend/cli/test/auth/auth.test.ts index 14f42185..82fcc566 100644 --- a/backend/cli/test/auth/auth.test.ts +++ b/backend/cli/test/auth/auth.test.ts @@ -118,3 +118,73 @@ await Auth.set(process.argv[2], { type: "api", key: process.argv[3] }) await fs.rm(root, { recursive: true, force: true }) } }) + +test("provider logout in one server revokes inherited BYOK children in another", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-auth-revision-")) + const mutate = path.join(root, "mutate.ts") + const worker = path.join(root, "worker.ts") + const ready = path.join(root, "ready") + const auth = new URL("../../src/auth/index.ts", import.meta.url).href + const lifecycle = new URL("../../src/credentials/lifecycle.ts", import.meta.url).href + const openscience = new URL("../../src/openscience/index.ts", import.meta.url).href + await Bun.write( + mutate, + [ + `import { Auth } from ${JSON.stringify(auth)}`, + `if (process.argv[2] === "remove") await Auth.remove("openai")`, + `else await Auth.set("openai", { type: "api", key: "sk-cross-process-provider" })`, + ].join("\n"), + ) + await Bun.write( + worker, + [ + `import fs from "node:fs/promises"`, + `import { spawn } from "node:child_process"`, + `import { CredentialLifecycle } from ${JSON.stringify(lifecycle)}`, + `import { OpenScience } from ${JSON.stringify(openscience)}`, + `await CredentialLifecycle.ensureFresh()`, + `const initial = await OpenScience.subprocessEnv(process.env)`, + `if (initial.OPENAI_API_KEY !== "sk-cross-process-provider") throw new Error("worker did not load provider key")`, + `const child = spawn(process.execPath, ["-e", "console.log(process.env.OPENAI_API_KEY || 'absent'); setInterval(() => {}, 1000)"], { env: initial, stdio: ["ignore", "pipe", "pipe"] })`, + `const inherited = await new Promise((resolve, reject) => { child.stdout.once("data", (data) => resolve(String(data).trim())); child.once("error", reject) })`, + `if (inherited !== "sk-cross-process-provider") throw new Error("child did not inherit provider key")`, + `let revoked = false`, + `CredentialLifecycle.onRevoke(async () => { revoked = true; child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)) })`, + `CredentialLifecycle.watch(25)`, + `await fs.writeFile(${JSON.stringify(ready)}, "ready")`, + `for (let i = 0; i < 400 && !revoked; i++) await Bun.sleep(10)`, + `await CredentialLifecycle.ensureFresh()`, + `if (!revoked || (child.exitCode === null && child.signalCode === null)) throw new Error("provider child was not revoked")`, + `const next = await OpenScience.subprocessEnv(process.env)`, + `if (next.OPENAI_API_KEY !== undefined) throw new Error("new child env retained removed provider key")`, + `CredentialLifecycle.stopWatching()`, + ].join("\n"), + ) + + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: root, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + } + const run = async (args: string[]) => { + const proc = Bun.spawn(args, { env, stdout: "pipe", stderr: "pipe" }) + const [exit, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (exit !== 0) throw new Error(error) + } + + try { + await run([process.execPath, mutate, "set"]) + const live = Bun.spawn([process.execPath, worker], { env, stdout: "pipe", stderr: "pipe" }) + for (let i = 0; i < 400 && !(await Bun.file(ready).exists()); i++) await Bun.sleep(10) + expect(await Bun.file(ready).exists()).toBe(true) + await run([process.execPath, mutate, "remove"]) + const [exit, error] = await Promise.all([live.exited, new Response(live.stderr).text()]) + if (exit !== 0) throw new Error(error) + expect(exit).toBe(0) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/auth/wellknown-command.test.ts b/backend/cli/test/auth/wellknown-command.test.ts new file mode 100644 index 00000000..dd6e83e5 --- /dev/null +++ b/backend/cli/test/auth/wellknown-command.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { fetchWellKnownAuth, runApprovedWellKnownAuth, WellKnownAuthApprovalRequired } from "../../src/cli/cmd/auth" +import { WellKnownAuthCommand } from "../../src/auth/wellknown-command" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("unsigned well-known auth commands", () => { + test("a non-interactive remote command is refused before its runner can execute", async () => { + const document = await fetchWellKnownAuth("https://auth.example.test", { + fetcher: (async () => + new Response( + JSON.stringify({ + auth: { + command: ["/bin/sh", "-c", "printf pwned > /tmp/remote-wellknown-rce"], + env: "EXAMPLE_TOKEN", + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch, + }) + let confirmed = false + let executed = false + + await expect( + runApprovedWellKnownAuth(document, { + interactive: false, + confirm: async () => { + confirmed = true + return true + }, + run: async () => { + executed = true + return "token" + }, + }), + ).rejects.toBeInstanceOf(WellKnownAuthApprovalRequired) + expect(confirmed).toBe(false) + expect(executed).toBe(false) + }) + + test("execution is bound to an explicit approval for the exact argv", async () => { + const document = await fetchWellKnownAuth("https://auth.example.test", { + fetcher: (async () => + new Response(JSON.stringify({ auth: { command: ["token-helper", "--print"], env: "EXAMPLE_TOKEN" } }), { + status: 200, + })) as unknown as typeof fetch, + }) + let prompt = "" + let argv: string[] = [] + const token = await runApprovedWellKnownAuth(document, { + interactive: true, + confirm: async (message) => { + prompt = message + return true + }, + run: async (input) => { + argv = input.argv + return "approved-token" + }, + }) + + expect(prompt).toContain(JSON.stringify(document.auth.command)) + expect(argv).toEqual(document.auth.command) + expect(token).toBe("approved-token") + }) + + test("malformed commands, env names, redirects and oversized documents fail closed", async () => { + const fetcher = (value: unknown, init: ResponseInit = {}) => + (async () => new Response(JSON.stringify(value), { status: 200, ...init })) as unknown as typeof fetch + + await expect( + fetchWellKnownAuth("https://auth.example.test", { + fetcher: fetcher({ auth: { command: [], env: "TOKEN" } }), + }), + ).rejects.toThrow() + await expect( + fetchWellKnownAuth("https://auth.example.test", { + fetcher: fetcher({ auth: { command: ["helper\0evil"], env: "TOKEN" } }), + }), + ).rejects.toThrow("argv cannot contain NUL") + await expect( + fetchWellKnownAuth("https://auth.example.test", { + fetcher: fetcher({ auth: { command: ["helper"], env: "TOKEN;EVIL=1" } }), + }), + ).rejects.toThrow("invalid environment variable name") + await expect( + fetchWellKnownAuth("https://user:secret@auth.example.test", { + fetcher: fetcher({ auth: { command: ["helper"], env: "TOKEN" } }), + }), + ).rejects.toThrow("must not contain credentials") + await expect( + fetchWellKnownAuth("https://auth.example.test?redirect=evil", { + fetcher: fetcher({ auth: { command: ["helper"], env: "TOKEN" } }), + }), + ).rejects.toThrow("must not contain a query or fragment") + await expect( + fetchWellKnownAuth("https://auth.example.test", { + maxBytes: 16, + fetcher: fetcher({ auth: { command: ["helper"], env: "TOKEN" } }), + }), + ).rejects.toThrow("exceeds 16 bytes") + }) + + test("the governed runner environment excludes ambient credentials and loader injection", () => { + const env = WellKnownAuthCommand.environment({ + PATH: "/usr/bin", + HOME: "/home/researcher", + LANG: "C.UTF-8", + AWS_PROFILE: "research", + OPENAI_API_KEY: "secret", + SYNSC_API_KEY: "secret", + LD_PRELOAD: "/tmp/evil.so", + PYTHONPATH: "/tmp/evil", + NODE_OPTIONS: "--require=/tmp/evil.js", + }) + + expect(env).toMatchObject({ PATH: "/usr/bin", HOME: "/home/researcher", AWS_PROFILE: "research" }) + expect(env.OPENAI_API_KEY).toBeUndefined() + expect(env.SYNSC_API_KEY).toBeUndefined() + expect(env.LD_PRELOAD).toBeUndefined() + expect(env.PYTHONPATH).toBeUndefined() + expect(env.NODE_OPTIONS).toBeUndefined() + }) + + test("an approved command runs through the governed one-shot process boundary", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const token = await WellKnownAuthCommand.run({ + argv: ["/bin/sh", "-c", "printf governed-token"], + timeoutMs: 5_000, + }) + expect(token).toBe("governed-token") + }, + }) + }) +}) diff --git a/backend/cli/test/compute/jobs-multiprocess.test.ts b/backend/cli/test/compute/jobs-multiprocess.test.ts new file mode 100644 index 00000000..9c270d57 --- /dev/null +++ b/backend/cli/test/compute/jobs-multiprocess.test.ts @@ -0,0 +1,337 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { ComputeJobs } from "../../src/compute/jobs" + +function isolatedEnv(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } +} + +// Six real servers each perform native ownership registration, durable store +// arbitration, and verified process-tree teardown. Under concurrent suite load +// this has measured 28s, so keep a meaningful margin above the old 30s edge. +test("independent servers preserve every concurrent compute lifecycle update", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-compute-race-")) + const workspace = path.join(root, "workspace") + const state = path.join(root, "jobs") + const runner = path.join(root, "run.ts") + const jobs = new URL("../../src/compute/jobs.ts", import.meta.url).href + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + const total = 6 + await fs.mkdir(workspace) + await Bun.write( + runner, + ` +import { ComputeJobs } from ${JSON.stringify(jobs)} +import { Instance } from ${JSON.stringify(instance)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +await Instance.provide({ + directory: process.argv[2], + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + const session = await Session.create({}) + const job = await ComputeJobs.start({ + name: process.argv[4], + command: "sleep 0.15", + target: { kind: "local" }, + sessionID: session.id, + }, { root: process.argv[3], workspace: process.argv[2] }) + const done = await ComputeJobs.wait(job.id, { root: process.argv[3], workspace: process.argv[2], timeout: 10_000 }) + console.log(JSON.stringify({ + id: done.id, + status: done.status, + trustRevision: done.authority?.trustRevision, + projectID: done.authority?.projectID, + sessionID: session.id, + })) + }, +}) +`, + ) + + try { + const processes = Array.from({ length: total }, (_, index) => + Bun.spawn([process.execPath, runner, workspace, state, `job-${index}`], { + env: isolatedEnv(root), + stdout: "pipe", + stderr: "pipe", + }), + ) + const results = await Promise.all( + processes.map(async (proc) => ({ + exit: await proc.exited, + output: await new Response(proc.stdout).text(), + error: await new Response(proc.stderr).text(), + })), + ) + expect(results.filter((item) => item.exit !== 0)).toEqual([]) + const outputs = results.map( + (item) => + JSON.parse(item.output.trim()) as { + id: string + status: string + trustRevision: number + projectID: string + sessionID: string + }, + ) + expect(new Set(outputs.map((item) => item.id)).size).toBe(total) + expect(new Set(outputs.map((item) => item.status))).toEqual(new Set(["succeeded"])) + expect(new Set(outputs.map((item) => item.trustRevision)).size).toBe(1) + expect(new Set(outputs.map((item) => item.projectID)).size).toBe(1) + + const storage = path.join(root, "data", "storage") + const projectID = outputs[0]!.projectID + const projects = (await fs.readdir(path.join(storage, "project"))).filter((item) => item.endsWith(".json")) + expect(projects).toEqual([`${projectID}.json`]) + for (const output of outputs) { + expect(await Bun.file(path.join(storage, "session", projectID, `${output.sessionID}.json`)).exists()).toBe(true) + expect( + await Bun.file(path.join(storage, "session_workspace", projectID, `${output.sessionID}.json`)).exists(), + ).toBe(true) + } + + const persisted = ComputeJobs.Job.array().parse(JSON.parse(await Bun.file(path.join(state, "jobs.json")).text())) + expect(persisted).toHaveLength(total) + expect(new Set(persisted.map((item) => item.id)).size).toBe(total) + expect(new Set(persisted.map((item) => item.name)).size).toBe(total) + expect(new Set(persisted.map((item) => item.status))).toEqual(new Set(["succeeded"])) + expect(await Bun.file(path.join(state, "jobs.json.lock")).exists()).toBe(false) + expect(await fs.readdir(path.join(state, "local-leases"))).toEqual([]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 60_000) + +test("independent servers share one durable Modal concurrency admission", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-modal-admission-")) + const workspace = path.join(root, "workspace") + const state = path.join(root, "jobs") + const runner = path.join(root, "modal.ts") + const gate = path.join(root, "release") + const launches = path.join(root, "launches.log") + const jobs = new URL("../../src/compute/jobs.ts", import.meta.url).href + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + await fs.mkdir(workspace) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { ComputeJobs } from ${JSON.stringify(jobs)} +import { Instance } from ${JSON.stringify(instance)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +const workspace = process.argv[2] +const root = process.argv[3] +const gate = process.argv[4] +const launches = process.argv[5] +const modal = { app: "openscience-test", image: "python:3.12-slim", network: "none", timeoutMinutes: 10, concurrency: 1 } +const credentials = { ...modal, tokenId: "ak-test", tokenSecret: "as-test" } +const provider = { + volume: (_project, id) => \`test-\${id}\`, + run: async (_context, spec, hooks) => { + await hooks.created(\`sandbox-\${spec.id}\`) + await fs.appendFile(launches, \`\${process.pid}\\n\`) + while (!(await Bun.file(gate).exists())) await Bun.sleep(20) + return { code: 0, outputs: [] } + }, + recover: async () => ({ code: 0, outputs: [] }), + find: async () => undefined, + close: async () => undefined, + release: async () => undefined, +} +await Instance.provide({ + directory: workspace, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + const session = await Session.create({}) + const request = { name: \`modal-\${process.pid}\`, command: "true", target: { kind: "modal" }, gpu: "none", sessionID: session.id } + const plan = await ComputeJobs.plan(request, { root, workspace, modal }) + try { + const job = await ComputeJobs.start({ ...request, approval: plan.digest }, { root, workspace, modal, credentials, provider }) + const done = await ComputeJobs.wait(job.id, { root, workspace, timeout: 10_000 }) + console.log(JSON.stringify({ ok: true, id: done.id, status: done.status })) + } catch (error) { + console.log(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })) + } + }, +}) +`, + ) + + try { + const processes = Array.from({ length: 2 }, () => + Bun.spawn([process.execPath, runner, workspace, state, gate, launches], { + env: isolatedEnv(root), + stdout: "pipe", + stderr: "pipe", + }), + ) + const deadline = Date.now() + 10_000 + while (!(await Bun.file(launches).exists()) && Date.now() < deadline) await Bun.sleep(20) + expect(await Bun.file(launches).exists()).toBe(true) + await Bun.sleep(300) + await Bun.write(gate, "release") + const results = await Promise.all( + processes.map(async (proc) => ({ + exit: await proc.exited, + output: await new Response(proc.stdout).text(), + error: await new Response(proc.stderr).text(), + })), + ) + expect(results.filter((item) => item.exit !== 0)).toEqual([]) + const outputs = results.map( + (item) => JSON.parse(item.output.trim()) as { ok: boolean; id?: string; status?: string; error?: string }, + ) + expect(outputs.filter((item) => item.ok)).toHaveLength(1) + expect(outputs.filter((item) => !item.ok)).toHaveLength(1) + expect(outputs.find((item) => !item.ok)?.error).toContain("Modal concurrency limit reached") + expect((await Bun.file(launches).text()).trim().split("\n")).toHaveLength(1) + const persisted = ComputeJobs.Job.array().parse(JSON.parse(await Bun.file(path.join(state, "jobs.json")).text())) + expect(persisted).toHaveLength(1) + expect(persisted[0]?.status).toBe("succeeded") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("independent servers serialize Modal cancel and release operations", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-modal-operations-")) + const workspace = path.join(root, "workspace") + const state = path.join(root, "jobs") + const runner = path.join(root, "operate.ts") + const operations = path.join(root, "operations.log") + const jobsUrl = new URL("../../src/compute/jobs.ts", import.meta.url).href + await fs.mkdir(workspace) + await fs.mkdir(state) + + const modalSpec = { + app: "openscience-test", + image: "python:3.12-slim", + packages: [], + gpu: "none", + network: "none" as const, + timeout_minutes: 10, + uploads: [], + upload_bytes: 0, + approval: "a".repeat(64), + sdk: "test", + volume: "test-volume", + } + const common = { + command: "true", + cwd: workspace, + target: { kind: "modal" as const }, + target_label: "Modal", + scheduler: "none" as const, + created_at: new Date(Date.now() - 10_000).toISOString(), + modal: modalSpec, + } + const cancelJob = ComputeJobs.Job.parse({ + ...common, + id: "cancel-job", + name: "cancel once", + status: "running", + started_at: new Date(Date.now() - 9_000).toISOString(), + remote_id: "sandbox-cancel", + lifecycle: { execution: "running", delivery: "none", resource: "active", recoverable: false }, + }) + const releaseJob = ComputeJobs.Job.parse({ + ...common, + id: "release-job", + name: "release once", + status: "succeeded", + started_at: new Date(Date.now() - 9_000).toISOString(), + completed_at: new Date(Date.now() - 1_000).toISOString(), + exit_code: 0, + remote_id: "sandbox-release", + lifecycle: { execution: "succeeded", delivery: "complete", resource: "unknown", recoverable: false }, + }) + await Bun.write(path.join(state, "jobs.json"), JSON.stringify([cancelJob, releaseJob])) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { ComputeJobs } from ${JSON.stringify(jobsUrl)} +const workspace = process.argv[2] +const root = process.argv[3] +const operation = process.argv[4] +const id = process.argv[5] +const log = process.argv[6] +const credentials = { + app: "openscience-test", image: "python:3.12-slim", network: "none", timeoutMinutes: 10, + concurrency: 1, tokenId: "ak-test", tokenSecret: "as-test", +} +const provider = { + volume: () => "test-volume", + run: async () => ({ code: 0, outputs: [] }), + recover: async () => ({ code: 0, outputs: [] }), + find: async () => undefined, + close: async () => undefined, + release: async () => { + await fs.appendFile(log, \`\${operation}\\n\`) + await Bun.sleep(150) + }, +} +const job = operation === "cancel" + ? await ComputeJobs.cancel(id, { root, workspace, credentials, provider }) + : await ComputeJobs.release(id, { root, workspace, credentials, provider }) +console.log(JSON.stringify({ id: job.id, status: job.status, resource: job.lifecycle?.resource })) +`, + ) + + try { + const specs = [ + ["cancel", cancelJob.id], + ["cancel", cancelJob.id], + ["release", releaseJob.id], + ["release", releaseJob.id], + ] as const + const processes = specs.map(([operation, id]) => + Bun.spawn([process.execPath, runner, workspace, state, operation, id, operations], { + env: isolatedEnv(root), + stdout: "pipe", + stderr: "pipe", + }), + ) + const results = await Promise.all( + processes.map(async (proc) => ({ + exit: await proc.exited, + output: await new Response(proc.stdout).text(), + error: await new Response(proc.stderr).text(), + })), + ) + expect(results.filter((item) => item.exit !== 0)).toEqual([]) + expect((await Bun.file(operations).text()).trim().split("\n").toSorted()).toEqual(["cancel", "release"]) + const persisted = ComputeJobs.Job.array().parse(JSON.parse(await Bun.file(path.join(state, "jobs.json")).text())) + expect(persisted.find((item) => item.id === cancelJob.id)).toMatchObject({ + status: "cancelled", + lifecycle: { resource: "closed" }, + }) + expect(persisted.find((item) => item.id === releaseJob.id)).toMatchObject({ + status: "succeeded", + lifecycle: { resource: "closed" }, + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/compute/jobs.test.ts b/backend/cli/test/compute/jobs.test.ts index 59539a3c..257e7d4a 100644 --- a/backend/cli/test/compute/jobs.test.ts +++ b/backend/cli/test/compute/jobs.test.ts @@ -9,6 +9,8 @@ import { Session } from "../../src/session" import { OpenScience } from "../../src/openscience" import { Sandbox } from "../../src/sandbox/sandbox" import { ExecutionAuthority } from "../../src/project/execution" +import { ArtifactStore } from "../../src/artifact/store" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" import { tmpdir, trustProject } from "../fixture/fixture" type StartOptions = NonNullable[1]> @@ -55,6 +57,7 @@ describe("ComputeJobs command adapters", () => { port: 2222, scheduler: "slurm" as const, workdir: "/scratch/team project", + concurrency: 4, } test("builds a non-interactive SSH command for a Slurm job", () => { @@ -108,6 +111,59 @@ describe("ComputeJobs command adapters", () => { expect(pbs).toContain("walltime=00:30:00") expect(ComputeJobs.command(input, { ...host, scheduler: "none" }).argv.at(-1)).toContain("exec") }) + + test("binds SSH resources, modules, and container into the approved digest", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "state") + const pinned = ComputeJobs.Host.parse({ + ...host, + scheduler: "none", + fingerprint: `SHA256:${"a".repeat(43)}`, + host_key: `hpc.example.org ssh-ed25519 ${Buffer.from("test-key").toString("base64")}`, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const request = { + sessionID: session.id, + name: "approved SSH contract", + command: "python3 train.py", + target: { kind: "ssh" as const, host_id: pinned.id }, + resources: { cpus: 4, gpus: 1, memory_gb: 16, time_minutes: 20, partition: "research" }, + modules: ["python/3.12"], + container: "/images/research.sif", + } + const approved = await ComputeJobs.plan(request, { root, workspace: tmp.path, hosts: [pinned] }) + for (const mutation of [ + { resources: { ...request.resources, gpus: 2 } }, + { modules: ["python/3.13"] }, + { container: "/images/unreviewed.sif" }, + ]) { + await expect( + ComputeJobs.start( + { ...request, ...mutation, approval: approved.digest }, + { root, workspace: tmp.path, hosts: [pinned] }, + ), + ).rejects.toThrow("The SSH run must be approved using its current plan digest") + } + for (const changed of [ + { ...pinned, user: "other" }, + { ...pinned, port: 2200 }, + { ...pinned, workdir: "/different/base" }, + ]) { + await expect( + ComputeJobs.start( + { ...request, approval: approved.digest }, + { root, workspace: tmp.path, hosts: [changed] }, + ), + ).rejects.toThrow("The SSH run must be approved using its current plan digest") + } + expect(await ComputeJobs.list({ root, workspace: tmp.path })).toEqual([]) + }, + }) + }) }) describe("ComputeJobs persistence", () => { @@ -314,10 +370,20 @@ describe("ComputeJobs local lifecycle", () => { size: 22, }) expect(finished.artifacts?.[0]?.sha256).toMatch(/^[a-f0-9]{64}$/) + const artifactID = finished.artifacts?.[0]?.artifact_id + const versionID = finished.artifacts?.[0]?.version_id + expect(artifactID).toMatch(/^art_/) + expect(versionID).toMatch(/^ver_/) + expect(finished.artifacts?.[0]?.version).toBe(1) expect(finished.checkpoint).toMatchObject({ path: "checkpoints/latest.ckpt", size: 5, + version: 1, }) + expect(finished.checkpoint?.artifact_id).toMatch(/^art_/) + expect(finished.checkpoint?.version_id).toMatch(/^ver_/) + const immutable = await ArtifactStore.read(job.authority!.projectID, artifactID!, versionID!) + expect(await immutable?.content.text()).toBe("metric,value\nloss,0.1\n") expect(finished.reproducibility?.git?.dirty).toBe(true) expect(finished.reproducibility?.lockfiles).toContainEqual( expect.objectContaining({ @@ -331,15 +397,17 @@ describe("ComputeJobs local lifecycle", () => { kind: "artifact", path: { status: "available", value: "outputs/results.csv" }, sha256: finished.artifacts?.[0]?.sha256, - version_id: { status: "unavailable", reason: "not_versioned" }, - version: { status: "unavailable", reason: "not_versioned" }, + artifact_id: { status: "available", value: finished.artifacts?.[0]?.artifact_id }, + version_id: { status: "available", value: finished.artifacts?.[0]?.version_id }, + version: { status: "available", value: 1 }, }), expect.objectContaining({ kind: "checkpoint", path: { status: "available", value: "checkpoints/latest.ckpt" }, sha256: finished.checkpoint?.sha256, - version_id: { status: "unavailable", reason: "not_versioned" }, - version: { status: "unavailable", reason: "not_versioned" }, + artifact_id: { status: "available", value: finished.checkpoint?.artifact_id }, + version_id: { status: "available", value: finished.checkpoint?.version_id }, + version: { status: "available", value: 1 }, }), ]), ) @@ -464,6 +532,114 @@ describe("ComputeJobs local lifecycle", () => { }) }) + test("cancels credential-bearing children when the host credential snapshot changes", async () => { + if (!Sandbox.available()) return + await using tmp = await tmpdir() + const root = path.join(tmp.path, "state") + const job = await start( + { + name: "credential snapshot", + command: "sleep 30", + cwd: tmp.path, + target: { kind: "local" }, + }, + { root, workspace: tmp.path }, + ) + for (const _ of Array.from({ length: 100 })) { + const current = await ComputeJobs.get(job.id, { root, workspace: tmp.path }) + if (current?.status === "running") break + await Bun.sleep(20) + } + + expect(await ComputeJobs.cancelCredentialProcesses()).toBe(1) + expect((await ComputeJobs.wait(job.id, { root, workspace: tmp.path, timeout: 5_000 })).status).toBe("cancelled") + }) + + const posixTest = process.platform === "win32" ? test.skip : test + + posixTest("reaps same-group background work before completing a local job", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "state") + const marker = path.join(tmp.path, "compute-descendant.pid") + const release = path.join(tmp.path, "compute-release") + let descendantPID = 0 + let descendantIdentity: string | undefined + let job: ComputeJobs.Job | undefined + try { + job = await start( + { + name: "background descendant regression", + command: [ + "sleep 600 &", + 'child="$!";', + `printf %s "$child" > ${ComputeJobs.quote(marker)};`, + `while [ ! -f ${ComputeJobs.quote(release)} ]; do sleep 0.02; done`, + ].join(" "), + target: { kind: "local" }, + }, + { root, workspace: tmp.path }, + ) + for (let attempt = 0; attempt < 200 && !(await Bun.file(marker).exists()); attempt++) await Bun.sleep(10) + expect(await Bun.file(marker).exists()).toBe(true) + descendantPID = Number((await Bun.file(marker).text()).trim()) + descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + expect(descendantIdentity).toMatch(/^[a-f0-9]{64}$/) + + await Bun.write(release, "release") + const finished = await ComputeJobs.wait(job.id, { root, workspace: tmp.path, timeout: 5_000 }) + + expect(finished.status).toBe("succeeded") + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(false) + } finally { + if (job) await ComputeJobs.cancel(job.id, { root, workspace: tmp.path }).catch(() => undefined) + if (descendantPID && (await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) { + process.kill(descendantPID, "SIGKILL") + } + } + }) + + posixTest("credential revocation reaps compute work that starts a new session", async () => { + const python = Bun.which("python3") + if (!python) return + await using tmp = await tmpdir() + const root = path.join(tmp.path, "state") + const marker = path.join(tmp.path, "compute-setsid.pid") + const script = [ + "import subprocess, sys, time", + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'], start_new_session=True)", + "open(sys.argv[1], 'w').write(str(child.pid))", + "time.sleep(600)", + ].join("; ") + let descendantPID = 0 + let descendantIdentity: string | undefined + let job: ComputeJobs.Job | undefined + try { + job = await start( + { + name: "new session descendant regression", + command: `${ComputeJobs.quote(python)} -c ${ComputeJobs.quote(script)} ${ComputeJobs.quote(marker)}`, + target: { kind: "local" }, + }, + { root, workspace: tmp.path }, + ) + for (let attempt = 0; attempt < 200 && !(await Bun.file(marker).exists()); attempt++) await Bun.sleep(10) + expect(await Bun.file(marker).exists()).toBe(true) + descendantPID = Number((await Bun.file(marker).text()).trim()) + descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + expect(descendantIdentity).toMatch(/^[a-f0-9]{64}$/) + + expect((await ComputeJobs.cancel(job.id, { root, workspace: tmp.path })).status).toBe("cancelled") + const finished = await ComputeJobs.wait(job.id, { root, workspace: tmp.path, timeout: 5_000 }) + expect(finished.status).toBe("cancelled") + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(false) + } finally { + if (job) await ComputeJobs.cancel(job.id, { root, workspace: tmp.path }).catch(() => undefined) + if (descendantPID && (await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) { + process.kill(descendantPID, "SIGKILL") + } + } + }) + test("does not relabel a completed job when cancellation arrives late", async () => { await using tmp = await tmpdir() const root = path.join(tmp.path, "state") @@ -525,6 +701,55 @@ describe("ComputeJobs local lifecycle", () => { }) describe("ComputeJobs Modal governance", () => { + test("returns the approved dispatch before the remote workload finishes", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "state") + const gate = Promise.withResolvers() + const entered = Promise.withResolvers() + const provider = modalProvider({ + run: async (_context, spec, hooks) => { + await hooks.created(`sandbox-${spec.id}`) + entered.resolve() + await gate.promise + return { code: 0, outputs: [] } + }, + }) + const request = { + name: "asynchronous modal job", + command: "sleep 3600", + target: { kind: "modal" as const }, + gpu: "none", + } + const prepared = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const plan = await ComputeJobs.plan({ ...request, sessionID: session.id }, { root, workspace: tmp.path, modal }) + return { session, plan } + }, + }) + + const job = await Promise.race([ + Instance.provide({ + directory: tmp.path, + fn: () => + ComputeJobs.start( + { ...request, sessionID: prepared.session.id, approval: prepared.plan.digest }, + { root, workspace: tmp.path, modal, credentials, provider }, + ), + }), + Bun.sleep(2_000).then(() => Promise.reject(new Error("approved dispatch waited for the remote workload"))), + ]) + + await entered.promise + expect(job.status).toBe("queued") + expect((await ComputeJobs.get(job.id, { root, workspace: tmp.path }))?.status).toBe("running") + + gate.resolve() + expect((await ComputeJobs.wait(job.id, { root, workspace: tmp.path, timeout: 5_000 })).status).toBe("succeeded") + }) + test("records a Modal sandbox timeout as a terminal timed-out job", async () => { await using tmp = await tmpdir() const root = path.join(tmp.path, "state") @@ -756,11 +981,15 @@ describe("ComputeJobs Modal governance", () => { test("retries delivery from the durable Modal resource without rerunning the command", async () => { await using tmp = await tmpdir() const root = path.join(tmp.path, "state") + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() const calls = { run: 0, recover: 0, release: 0 } const provider = modalProvider({ run: async (_context, spec, hooks) => { calls.run++ await hooks.created(`sandbox-${spec.id}`) + entered.resolve() + await finish.promise return { code: 0, outputs: [{ path: "../escape", staging: tmp.path, size: 0 }] } }, recover: async (_context, spec, id, hooks) => { @@ -800,21 +1029,21 @@ describe("ComputeJobs Modal governance", () => { { root, workspace: tmp.path, modal, credentials, provider }, ), }) - const delivery = async (attempts = 100): Promise => { - const current = await ComputeJobs.get(job.id, { root, workspace: tmp.path }) - if (current?.lifecycle?.delivery === "failed") return current - if (!attempts) throw new Error("Timed out waiting for recoverable Modal output") - await Bun.sleep(20) - return delivery(attempts - 1) - } - const failed = await delivery() - - expect(failed.status).toBe("succeeded") - expect(failed.lifecycle?.recoverable).toBe(true) + await entered.promise expect(calls).toEqual({ run: 1, recover: 0, release: 0 }) await Bun.write(path.join(root, "jobs", `${job.id}.log`), "last visible output\n") - await ComputeJobs.retry(job.id, { root, workspace: tmp.path, credentials, provider }) + const retry = ComputeJobs.retry(job.id, { root, workspace: tmp.path, credentials, provider }) + const beforeFinish = await Promise.race([ + retry.then( + () => "settled" as const, + () => "settled" as const, + ), + Bun.sleep(50).then(() => "waiting" as const), + ]) + finish.resolve() + expect(beforeFinish).toBe("waiting") + await retry const complete = async (attempts = 100): Promise => { const current = await ComputeJobs.get(job.id, { root, workspace: tmp.path }) if (current?.lifecycle?.resource === "closed") return current diff --git a/backend/cli/test/compute/modal-volume.test.ts b/backend/cli/test/compute/modal-volume.test.ts index ea6656e4..ef47847e 100644 --- a/backend/cli/test/compute/modal-volume.test.ts +++ b/backend/cli/test/compute/modal-volume.test.ts @@ -53,7 +53,7 @@ async function fixture() { " assert os.environ.get('MODAL_TOKEN_SECRET') == 'as-test'", " assert name == 'job-volume'", " assert environment_name == 'main'", - " return Handle(os.environ['FAKE_MODAL_ROOT'])", + ` return Handle(${JSON.stringify(volume)})`, "", ].join("\n"), ) @@ -65,12 +65,56 @@ async function fixture() { tokenSecret: "as-test", environment: "main", command: [python, "-I", "-c", run, root, await ModalVolume.driverPath()], - env: { ...process.env, FAKE_MODAL_ROOT: volume }, } return { context, root, staging } } describe("ModalVolume", () => { + test("passes only runtime fields to the token-bearing bridge", () => { + const env = ModalVolume.environment({ + PATH: "/usr/bin:/bin", + HOME: "/home/researcher", + LANG: "en_US.UTF-8", + OPENAI_API_KEY: "provider-secret", + AWS_SECRET_ACCESS_KEY: "cloud-secret", + MODAL_TOKEN_SECRET: "old-control-plane-secret", + OPENSCIENCE_CONFIG_CONTENT: "control-plane-state", + DYLD_INSERT_LIBRARIES: "/tmp/inject.dylib", + PYTHONSTARTUP: "/tmp/startup.py", + }) + + expect(env.PATH).toBe("/usr/bin:/bin") + expect(env.HOME).toBe("/home/researcher") + expect(env.LANG).toBe("en_US.UTF-8") + expect(env.PYTHONNOUSERSITE).toBe("1") + expect(env.OPENAI_API_KEY).toBeUndefined() + expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(env.MODAL_TOKEN_SECRET).toBeUndefined() + expect(env.OPENSCIENCE_CONFIG_CONTENT).toBeUndefined() + expect(env.DYLD_INSERT_LIBRARIES).toBeUndefined() + expect(env.PYTHONSTARTUP).toBeUndefined() + }) + + test("redacts the exact Modal token pair from bounded bridge failures", async () => { + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the Modal Volume driver test") + const error = await ModalVolume.check({ + tokenId: "ak-never-log-this-id", + tokenSecret: "as-never-log-this-secret", + command: [ + python, + "-I", + "-c", + "import os,sys; sys.stderr.write(os.environ['MODAL_TOKEN_ID'] + ':' + os.environ['MODAL_TOKEN_SECRET']); sys.exit(3)", + ], + }).catch((value) => value) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain("[REDACTED]:[REDACTED]") + expect((error as Error).message).not.toContain("ak-never-log-this-id") + expect((error as Error).message).not.toContain("as-never-log-this-secret") + }) + test("shares one complete driver path across concurrent callers", async () => { const paths = await Promise.all(Array.from({ length: 20 }, () => ModalVolume.driverPath())) expect(new Set(paths).size).toBe(1) @@ -106,7 +150,10 @@ describe("ModalVolume", () => { ]) }) - test("uses control-plane list and download operations without a sandbox", async () => { + // This is four independent control-plane calls. Each call deliberately + // completes durable helper ownership and descendant reaping before the next + // one starts, so their combined deadline must not inherit Bun's 5s default. + test("uses the governed control-plane bridge for list and download operations", async () => { const item = await fixture() expect(await ModalVolume.check(item.context)).toBe("test-control-plane") @@ -127,7 +174,7 @@ describe("ModalVolume", () => { ]) expect(downloaded.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256))).toBe(true) expect(await Bun.file(path.join(item.staging, "outputs", "model.bin")).text()).toBe("weights") - }) + }, 30_000) test("waits for a durable marker inside one driver process", async () => { const item = await fixture() diff --git a/backend/cli/test/compute/ssh-adapter.test.ts b/backend/cli/test/compute/ssh-adapter.test.ts new file mode 100644 index 00000000..c8d6d6fd --- /dev/null +++ b/backend/cli/test/compute/ssh-adapter.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test" +import crypto from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { SshAdapter } from "../../src/compute/ssh/adapter" + +test("accepts only Slurm COMPLETED 0:0 as a successful terminal result", async () => { + expect(await SshAdapter.slurm("COMPLETED", "0:0")).toMatchObject({ state: "done", code: 0 }) + expect(await SshAdapter.slurm("CANCELLED by 1000", "0:15")).toMatchObject({ state: "cancelled" }) + expect(await SshAdapter.slurm("RUNNING", "0:0")).toMatchObject({ state: "running" }) + for (const [state, exit] of [ + ["FAILED", "1:0"], + ["TIMEOUT", "0:9"], + ["OUT_OF_MEMORY", "0:0"], + ["NODE_FAIL", "0:0"], + ["COMPLETED", "0:9"], + ["COMPLETED", "2:0"], + ] as const) { + const result = await SshAdapter.slurm(state, exit) + expect(result.state).toBe("done") + expect(result.code).toBeGreaterThan(0) + } +}) + +async function archive(root: string, relative: string, content: string) { + const source = await fs.mkdtemp(path.join(root, "archive-source-")) + const files = path.join(source, "files") + const target = path.join(files, relative) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content) + const manifest = { + files: [ + { + path: relative, + size: Buffer.byteLength(content), + sha256: crypto.createHash("sha256").update(content).digest("hex"), + }, + ], + } + await fs.writeFile(path.join(source, "manifest.json"), JSON.stringify(manifest)) + const targetArchive = path.join(root, `${crypto.randomUUID()}.tar`) + const proc = Bun.spawn(["tar", "-cf", targetArchive, "-C", source, "manifest.json", "files"], { + stdout: "ignore", + stderr: "pipe", + }) + const [code, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (code !== 0) throw new Error(error) + await fs.rm(source, { recursive: true, force: true }) + return targetArchive +} + +test("installs SSH outputs beneath an inode-pinned workspace while an ancestor name is swapped", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-ssh-delivery-")) + const archivePath = await archive(root, "results/value.bin", Buffer.alloc(2 * 1024 * 1024, 7).toString("binary")) + for (const attempt of Array.from({ length: 25 }, (_, index) => index)) { + const workspace = path.join(root, `workspace-${attempt}`) + const outside = path.join(root, `outside-${attempt}`) + const alias = path.join(workspace, "results") + const parked = path.join(workspace, "parked") + await Promise.all([fs.mkdir(alias, { recursive: true }), fs.mkdir(outside)]) + const stop = new AbortController() + let cycles = 0 + const swapped = (async () => { + while (!stop.signal.aborted) { + await fs.rename(alias, parked).catch(() => undefined) + await fs.symlink(outside, alias).catch(() => undefined) + await fs.rm(alias, { force: true }).catch(() => undefined) + await fs.rename(parked, alias).catch(() => undefined) + cycles++ + } + })() + const delivered = await SshAdapter.deliver(archivePath, workspace).then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error: error instanceof Error ? error.message : String(error) }), + ) + stop.abort() + await swapped + expect(cycles).toBeGreaterThan(0) + expect(await Bun.file(path.join(outside, "value.bin")).exists()).toBe(false) + const accepted = [path.join(alias, "value.bin"), path.join(parked, "value.bin")] + const published = (await Promise.all(accepted.map((item) => Bun.file(item).exists()))).filter(Boolean) + if (delivered.ok) { + expect(delivered.value.map((item) => item.path)).toEqual(["results/value.bin"]) + expect(published).toHaveLength(1) + } else { + expect(delivered.error).toContain("SSH output destination changed during delivery") + expect(published).toHaveLength(0) + } + for (const folder of [alias, parked]) { + const names = await fs.readdir(folder).catch(() => []) + expect(names.some((name) => name.endsWith(".openscience.tmp"))).toBe(false) + } + } + await fs.rm(root, { recursive: true, force: true }) +}, 30_000) + +test("SSH output delivery is idempotent but never replaces different workspace bytes", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-ssh-existing-")) + const workspace = path.join(root, "workspace") + const target = path.join(workspace, "results/value.txt") + const archivePath = await archive(root, "results/value.txt", "remote-result\n") + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, "local-work\n") + await expect(SshAdapter.deliver(archivePath, workspace)).rejects.toThrow( + "Refusing to replace an existing workspace file", + ) + expect(await fs.readFile(target, "utf8")).toBe("local-work\n") + await fs.writeFile(target, "remote-result\n") + expect((await SshAdapter.deliver(archivePath, workspace)).map((item) => item.path)).toEqual(["results/value.txt"]) + expect(await fs.readFile(target, "utf8")).toBe("remote-result\n") + await fs.rm(root, { recursive: true, force: true }) +}) diff --git a/backend/cli/test/compute/ssh-integration.test.ts b/backend/cli/test/compute/ssh-integration.test.ts new file mode 100644 index 00000000..1652c2a1 --- /dev/null +++ b/backend/cli/test/compute/ssh-integration.test.ts @@ -0,0 +1,417 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import { ComputeJobs } from "../../src/compute/jobs" + +async function run(argv: string[], env: Record = process.env) { + const proc = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`${argv[0]} exited ${code}: ${stderr}`) + return stdout +} + +async function port() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") return reject(new Error("no fixture port")) + server.close((error) => (error ? reject(error) : resolve(address.port))) + }) + }) +} + +function environment(root: string, socket: string) { + return { + ...process.env, + SSH_AUTH_SOCK: socket, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "xdg-config"), + XDG_DATA_HOME: path.join(root, "xdg-data"), + XDG_STATE_HOME: path.join(root, "xdg-state"), + } +} + +function fixtureConfig(input: { listen: number; root: string; hostKey: string; authorized: string }) { + return [ + `Port ${input.listen}`, + "ListenAddress 127.0.0.1", + `HostKey ${input.hostKey}`, + `PidFile ${path.join(input.root, "sshd.pid")}`, + `AuthorizedKeysFile ${input.authorized}`, + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "ChallengeResponseAuthentication no", + "PubkeyAuthentication yes", + "UsePAM no", + "StrictModes no", + "AllowTcpForwarding no", + "AllowAgentForwarding no", + "PermitTunnel no", + "X11Forwarding no", + "LogLevel VERBOSE", + "", + ].join("\n") +} + +test("the real-sshd fixture stays portable without relaxing its isolation", () => { + const config = fixtureConfig({ + listen: 22022, + root: "/tmp/openscience-sshd-fixture", + hostKey: "/tmp/openscience-sshd-fixture/host-ed25519", + authorized: "/tmp/openscience-sshd-fixture/authorized_keys", + }) + const directives = config.split("\n") + + // PerSourcePenalties was introduced after Ubuntu 24.04's OpenSSH 9.6. The + // fixture does not need to override that daemon-side abuse protection. + expect(directives.some((line) => line.startsWith("PerSourcePenalties "))).toBe(false) + expect(directives).toContain("HostKey /tmp/openscience-sshd-fixture/host-ed25519") + expect(directives).toContain("AuthorizedKeysFile /tmp/openscience-sshd-fixture/authorized_keys") + expect(directives).toContain("ListenAddress 127.0.0.1") + expect(directives).toContain("PasswordAuthentication no") + expect(directives).toContain("KbdInteractiveAuthentication no") + expect(directives).toContain("ChallengeResponseAuthentication no") + expect(directives).toContain("PubkeyAuthentication yes") + expect(directives).toContain("UsePAM no") + expect(directives).toContain("AllowTcpForwarding no") + expect(directives).toContain("AllowAgentForwarding no") + expect(directives).toContain("PermitTunnel no") + expect(directives).toContain("X11Forwarding no") +}) + +test("dispatches through a real OpenSSH daemon and reattaches from a fresh server process", async () => { + const sshd = "/usr/sbin/sshd" + if (!(await Bun.file(sshd).exists())) return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-sshd-")) + const workspace = path.join(root, "workspace") + const state = path.join(root, "jobs") + const remote = path.join(root, "remote") + const hostKey = path.join(root, "host-ed25519") + const clientKey = path.join(root, "client-ed25519") + const authorized = path.join(root, "authorized_keys") + const config = path.join(root, "sshd_config") + const sessionFile = path.join(root, "session") + const jobFile = path.join(root, "job") + const hostFile = path.join(root, "host.json") + const daemonLog = path.join(root, "sshd.log") + const fixture = new URL("../fixture/ssh-compute-process.ts", import.meta.url).pathname + const listen = await port() + let daemon: ReturnType | undefined + let agentPid: number | undefined + try { + await Promise.all([fs.mkdir(workspace), fs.mkdir(remote), fs.mkdir(path.join(root, "home"), { recursive: true })]) + await fs.writeFile(path.join(workspace, "input.txt"), "payload\n") + await run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", hostKey]) + await run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", clientKey]) + await fs.copyFile(`${clientKey}.pub`, authorized) + await fs.chmod(authorized, 0o600) + await fs.writeFile(config, fixtureConfig({ listen, root, hostKey, authorized })) + const agent = await run(["ssh-agent", "-s"]) + const socket = agent.match(/SSH_AUTH_SOCK=([^;]+)/)?.[1] + agentPid = Number(agent.match(/SSH_AGENT_PID=([0-9]+)/)?.[1]) + if (!socket || !Number.isInteger(agentPid)) throw new Error("ssh-agent did not publish its environment") + const env = environment(root, socket) + await run(["ssh-add", clientKey], env) + const listed = await run(["ssh-add", "-l"], env) + if (!listed.includes("ED25519")) throw new Error(`SSH fixture agent did not retain the test key: ${listed}`) + const logFile = await fs.open(daemonLog, "w", 0o600) + daemon = Bun.spawn([sshd, "-D", "-e", "-f", config], { env, stdout: "ignore", stderr: logFile.fd }) + await logFile.close() + const host = { + id: "real-openssh", + label: "OpenSSH fixture", + host: "127.0.0.1", + user: os.userInfo().username, + port: listen, + scheduler: "none" as const, + workdir: remote, + concurrency: 1, + } + const directDeadline = Date.now() + 3_000 + let direct = "" + while (Date.now() < directDeadline) { + direct = await run( + [ + "ssh", + "-vv", + "-T", + "-F", + "/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(listen), + `${host.user}@${host.host}`, + "true", + ], + env, + ).then( + () => "ok", + (error) => String(error), + ) + if (direct === "ok") break + await Bun.sleep(50) + } + if (direct !== "ok") throw new Error(`Direct fixture SSH failed: ${direct}`) + process.env.SSH_AUTH_SOCK = socket + const deadline = Date.now() + 8_000 + let probe: ComputeJobs.Probe | undefined + while (Date.now() < deadline) { + if (!(await Bun.file(path.join(root, "sshd.pid")).exists())) { + await Bun.sleep(50) + continue + } + const scanned = await import("../../src/compute/ssh/adapter").then((module) => module.SshAdapter.scan(host)) + probe = await ComputeJobs.probe({ ...host, ...scanned }) + if (probe.ok) break + await Bun.sleep(50) + } + if (!probe?.ok) { + throw new Error( + `${probe?.error ?? `OpenSSH fixture did not become ready (sshd exit ${daemon.exitCode})`}\n${await fs.readFile(daemonLog, "utf8")}`, + ) + } + expect(probe?.fingerprint).toStartWith("SHA256:") + const pinned = ComputeJobs.Host.parse({ ...host, fingerprint: probe?.fingerprint, host_key: probe?.host_key }) + const unavailableScheduler = await ComputeJobs.probe({ ...pinned, scheduler: "slurm" }) + expect(unavailableScheduler.ok).toBe(false) + expect(unavailableScheduler.error).toContain("Slurm (sbatch, squeue, sacct, scancel)") + await fs.writeFile(hostFile, JSON.stringify(pinned)) + if (process.platform === "linux") { + const commands = (value: string) => value.match(/Starting session: command/g)?.length ?? 0 + const before = commands(await fs.readFile(daemonLog, "utf8")) + const failed = await run( + [ + process.execPath, + fixture, + "start", + workspace, + path.join(root, "failed-jobs"), + hostFile, + path.join(root, "failed-session"), + path.join(root, "failed-job"), + ], + { ...env, OPENSCIENCE_SSH_TEST_REGISTRATION_FAILURE: "1" }, + ).then( + () => "unexpected-success", + (error) => String(error), + ) + expect(failed).toContain("Injected SSH control registration failure") + expect(commands(await fs.readFile(daemonLog, "utf8"))).toBe(before) + } + const first = await run([process.execPath, fixture, "start", workspace, state, hostFile, sessionFile, jobFile], env) + const started = JSON.parse(first.trim()) as { id: string; remote_id: string; fingerprint: string } + expect(started.remote_id).toMatch(/^pid:[0-9]+$/) + expect(started.fingerprint).toBe(pinned.fingerprint!) + expect(await fs.readFile(path.join(state, "jobs.json"), "utf8")).not.toContain('"owner"') + const second = await Promise.race([ + run([process.execPath, fixture, "recover", workspace, state, hostFile, sessionFile, jobFile], env), + Bun.sleep(22_000).then(async () => { + throw new Error( + `SSH recovery timed out\nJOBS:\n${await fs.readFile(path.join(state, "jobs.json"), "utf8").catch(() => "missing")}\nSSHD:\n${await fs.readFile(daemonLog, "utf8")}`, + ) + }), + ]) + const recovered = JSON.parse(second.trim()) as { + id: string + status: string + remote_id: string + lifecycle: { delivery: string; resource: string } + artifacts: { path: string; sha256: string }[] + log: string + events: string + } + expect(recovered).toMatchObject({ + id: started.id, + status: "succeeded", + remote_id: started.remote_id, + lifecycle: { delivery: "complete", resource: "closed" }, + }) + expect(recovered.log).toContain("remote:payload") + expect(recovered.events).toContain(`Submitted ${started.remote_id}`) + expect(recovered.artifacts.map((item) => item.path)).toEqual(["outputs/result.txt"]) + expect(await fs.readFile(path.join(workspace, "outputs/result.txt"), "utf8")).toBe("verified:payload\n") + expect(await Bun.file(path.join(remote, ".openscience", "jobs", started.id)).exists()).toBe(false) + + const long = JSON.parse( + ( + await run([process.execPath, fixture, "start-cancel", workspace, state, hostFile, sessionFile, jobFile], env) + ).trim(), + ) as { id: string; remote_id: string } + expect(long.remote_id).toMatch(/^pid:[0-9]+$/) + const remoteRuntime = JSON.parse( + await fs.readFile(path.join(remote, ".openscience", "jobs", long.id, "runtime.json"), "utf8"), + ) as { containment?: string; subreaper?: boolean; responsibility?: number } + const containment = remoteRuntime.containment + if (!containment) throw new Error("Remote SSH supervisor did not publish a containment primitive") + expect(["linux-subreaper", "systemd-scope", "darwin-responsibility"]).toContain(containment) + if (process.platform === "linux") { + expect(remoteRuntime.subreaper || remoteRuntime.containment === "systemd-scope").toBe(true) + } + if (process.platform === "darwin") { + expect(remoteRuntime.containment).toBe("darwin-responsibility") + expect(remoteRuntime.responsibility).toBeGreaterThan(0) + } + const cancelled = JSON.parse( + (await run([process.execPath, fixture, "cancel", workspace, state, hostFile, sessionFile, jobFile], env)).trim(), + ) as { + id: string + status: string + remote_id: string + lifecycle: { execution: string; resource: string } + events: string + } + expect(cancelled).toMatchObject({ + id: long.id, + status: "cancelled", + remote_id: long.remote_id, + lifecycle: { execution: "cancelled", resource: "closed" }, + }) + expect(cancelled.events).toContain("Released remote workspace") + expect(await Bun.file(path.join(remote, ".openscience", "jobs", long.id)).exists()).toBe(false) + + const stubborn = JSON.parse( + ( + await run( + [process.execPath, fixture, "start-ignore-term", workspace, state, hostFile, sessionFile, jobFile], + env, + ) + ).trim(), + ) as { id: string; remote_id: string } + expect(stubborn.remote_id).toMatch(/^pid:[0-9]+$/) + const stubbornPID = Number(stubborn.remote_id.slice(4)) + const stubbornCancelled = JSON.parse( + (await run([process.execPath, fixture, "cancel", workspace, state, hostFile, sessionFile, jobFile], env)).trim(), + ) as { status: string; lifecycle: { resource: string }; events: string } + expect(stubbornCancelled).toMatchObject({ status: "cancelled", lifecycle: { resource: "closed" } }) + expect(stubbornCancelled.events).toContain("Released remote workspace") + expect(await Bun.file(path.join(remote, ".openscience", "jobs", stubborn.id)).exists()).toBe(false) + const stubbornAlive = await run( + [ + "ssh", + "-T", + "-F", + "/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(listen), + `${host.user}@${host.host}`, + `kill -0 ${stubbornPID} >/dev/null 2>&1 && printf alive || printf gone`, + ], + env, + ) + expect(stubbornAlive).toBe("gone") + + const forked = JSON.parse( + ( + await run( + [process.execPath, fixture, "start-double-fork", workspace, state, hostFile, sessionFile, jobFile], + env, + ) + ).trim(), + ) as { id: string; remote_id: string } + const forkedFinished = JSON.parse( + (await run([process.execPath, fixture, "recover", workspace, state, hostFile, sessionFile, jobFile], env)).trim(), + ) as { status: string; lifecycle: { resource: string }; log: string } + expect(forkedFinished).toMatchObject({ status: "succeeded", lifecycle: { resource: "closed" } }) + expect(forkedFinished.log).toContain("leader-done") + expect(await fs.readFile(path.join(workspace, "outputs/double-fork.txt"), "utf8")).toBe("contained\n") + expect(await Bun.file(path.join(remote, ".openscience", "jobs", forked.id)).exists()).toBe(false) + + const forkCancel = JSON.parse( + ( + await run( + [process.execPath, fixture, "start-double-fork-cancel", workspace, state, hostFile, sessionFile, jobFile], + env, + ) + ).trim(), + ) as { id: string; remote_id: string } + const pidFile = path.join(remote, ".openscience", "jobs", forkCancel.id, "work", "double-fork.pid") + const forkDeadline = Date.now() + 3_000 + while (!(await Bun.file(pidFile).exists()) && Date.now() < forkDeadline) await Bun.sleep(20) + const forkPID = Number(await fs.readFile(pidFile, "utf8")) + expect(Number.isInteger(forkPID)).toBe(true) + const forkCancelled = JSON.parse( + (await run([process.execPath, fixture, "cancel", workspace, state, hostFile, sessionFile, jobFile], env)).trim(), + ) as { status: string; lifecycle: { resource: string }; events: string } + expect(forkCancelled).toMatchObject({ status: "cancelled", lifecycle: { resource: "closed" } }) + expect(forkCancelled.events).toContain("Released remote workspace") + const forkAlive = await run( + [ + "ssh", + "-T", + "-F", + "/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(listen), + `${host.user}@${host.host}`, + `kill -0 ${forkPID} >/dev/null 2>&1 && printf alive || printf gone`, + ], + env, + ) + expect(forkAlive).toBe("gone") + + const killed = await run( + [process.execPath, fixture, "start-killpoint", workspace, state, hostFile, sessionFile, jobFile], + env, + ).then( + () => "unexpected-success", + (error) => String(error), + ) + expect(killed).toContain("exited") + const durable = JSON.parse(await fs.readFile(path.join(state, "jobs.json"), "utf8")) as { + id: string + session_id: string + remote_id?: string + }[] + const acceptedRecord = durable.at(-1) + expect(acceptedRecord?.remote_id).toBeUndefined() + if (acceptedRecord) { + await Promise.all([ + fs.writeFile(jobFile, acceptedRecord.id), + fs.writeFile(sessionFile, acceptedRecord.session_id), + ]) + } + const acceptedJob = acceptedRecord?.id ?? "" + if (acceptedJob) { + const accepted = path.join(remote, ".openscience", "jobs", acceptedJob) + const markerDeadline = Date.now() + 3_000 + while (!(await Bun.file(path.join(accepted, "runtime.json")).exists()) && Date.now() < markerDeadline) + await Bun.sleep(20) + const recoveredKillpoint = JSON.parse( + ( + await run([process.execPath, fixture, "recover", workspace, state, hostFile, sessionFile, jobFile], env) + ).trim(), + ) as { status: string; remote_id: string; events: string; log: string } + expect(recoveredKillpoint.status).toBe("succeeded") + expect(recoveredKillpoint.remote_id).toMatch(/^pid:[0-9]+$/) + expect(recoveredKillpoint.events).toContain("Reattached") + expect(recoveredKillpoint.log.match(/remote:payload/g)).toHaveLength(1) + } + } finally { + daemon?.kill("SIGTERM") + if (daemon) await daemon.exited.catch(() => undefined) + if (agentPid) process.kill(agentPid, "SIGTERM") + await fs.rm(root, { recursive: true, force: true }) + } +}, 180_000) diff --git a/backend/cli/test/config/agent-color.test.ts b/backend/cli/test/config/agent-color.test.ts index 6e4f7b12..a9cad550 100644 --- a/backend/cli/test/config/agent-color.test.ts +++ b/backend/cli/test/config/agent-color.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, trustProject } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -46,6 +46,7 @@ test("Agent.get includes color from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const plan = await AgentSvc.get("plan") expect(plan?.color).toBe("#A855F7") }, diff --git a/backend/cli/test/credentials/process-boundary.test.ts b/backend/cli/test/credentials/process-boundary.test.ts new file mode 100644 index 00000000..69db7740 --- /dev/null +++ b/backend/cli/test/credentials/process-boundary.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import path from "node:path" + +test("credential-bearing subprocess snapshots only appear behind the admitted spawn boundary", async () => { + const root = path.resolve(import.meta.dir, "../..", "src") + const raw: string[] = [] + for await (const relative of new Bun.Glob("**/*.ts").scan({ cwd: root })) { + if (relative === "openscience/index.ts") continue + const source = await Bun.file(path.join(root, relative)).text() + if (source.includes("OpenScience.subprocessEnv(")) raw.push(relative) + } + expect(raw).toEqual([]) + + for (const relative of ["tool/bash.ts", "session/prompt.ts", "compute/jobs.ts", "mcp/index.ts"]) { + const source = await Bun.file(path.join(root, relative)).text() + expect(source, relative).toContain("OpenScience.withSubprocessEnv(") + } + + for (const relative of ["format/index.ts", "file/publication.ts"]) { + const source = await Bun.file(path.join(root, relative)).text() + expect(source, relative).toContain("OpenScience.kernelEnv(") + expect(source, relative).not.toContain("OpenScience.withSubprocessEnv(") + } + + const lifecycle = await Bun.file(path.join(root, "credentials/lifecycle.ts")).text() + expect(lifecycle.match(/return await action\(\)/g)?.length).toBe(2) + expect(lifecycle).not.toMatch(/await using lease[\s\S]{0,160}return action\(\)/) +}) diff --git a/backend/cli/test/credentials/process-ledger.test.ts b/backend/cli/test/credentials/process-ledger.test.ts new file mode 100644 index 00000000..43ff3200 --- /dev/null +++ b/backend/cli/test/credentials/process-ledger.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test" +import { spawn } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" +import { WindowsJobLauncher } from "../../src/process/windows-job-launcher" + +const linuxTest = process.platform === "linux" ? test : test.skip + +async function waitText(file: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value?.trim()) return value.trim() + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +linuxTest("revocation reaps a same-group descendant after its recorded leader exits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-credential-descendant-")) + const marker = path.join(root, "descendant.pid") + const projectID = `project-descendant-${crypto.randomUUID()}` + const id = `command-descendant-${crypto.randomUUID()}` + const leader = spawn( + "/bin/sh", + ["-c", 'sleep 600 & printf "%s" "$!" > "$1"; sleep 0.2; exit 0', "credential-ledger", marker], + { detached: true, stdio: "ignore" }, + ) + let descendantPID = 0 + let descendantIdentity: string | undefined + try { + expect( + await CredentialProcessLedger.register({ + id, + kind: "command", + pid: leader.pid!, + detached: true, + projectID, + sessionID: "session-descendant", + }), + ).toBe(true) + descendantPID = Number(await waitText(marker)) + descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + expect(descendantIdentity).toMatch(/^[a-f0-9]{64}$/) + await new Promise((resolve, reject) => { + leader.once("exit", () => resolve()) + leader.once("error", reject) + }) + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(true) + + expect(await CredentialProcessLedger.revoke({ kind: "command", projectID })).toBe(1) + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(false) + } finally { + await CredentialProcessLedger.revoke({ kind: "command", projectID }).catch(() => undefined) + if (descendantPID && (await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) { + process.kill(descendantPID, "SIGKILL") + } + if (leader.exitCode === null && leader.signalCode === null) leader.kill("SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } +}) + +linuxTest("command registration rejects a child that does not own a process group", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + try { + await expect( + CredentialProcessLedger.register({ + id: `command-non-group-${crypto.randomUUID()}`, + kind: "command", + pid: child.pid!, + detached: false, + projectID: "project-non-group", + sessionID: "session-non-group", + }), + ).rejects.toThrow("was not spawned in an owned process group") + } finally { + child.kill("SIGKILL") + await new Promise((resolve) => child.once("exit", () => resolve())) + } +}) + +test.skipIf(process.platform !== "darwin")("Darwin registration rejects an unwrapped durable runtime", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", + }) + try { + await expect( + CredentialProcessLedger.register({ + id: `command-unwrapped-${crypto.randomUUID()}`, + kind: "command", + pid: child.pid!, + detached: true, + projectID: "project-unwrapped", + }), + ).rejects.toThrow("macOS responsibility registration gate") + } finally { + process.kill(-child.pid!, "SIGKILL") + } +}) + +test.skipIf(process.platform !== "darwin")( + "Darwin revocation reaps a fully reparented double-fork daemon by kernel responsibility", + async () => { + const python = Bun.which("python3") + if (!python) return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-credential-responsibility-")) + const marker = path.join(root, "daemon.pid") + const projectID = `project-responsibility-${crypto.randomUUID()}` + const id = `command-responsibility-${crypto.randomUUID()}` + const daemonScript = [ + "import os, signal, time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "if os.fork(): os._exit(0)", + "os.setsid()", + "if os.fork(): os._exit(0)", + `marker = open(${JSON.stringify(marker)}, 'w')`, + "marker.write(str(os.getpid()))", + "marker.close()", + "time.sleep(600)", + ].join("\n") + const supervisorScript = [ + "import subprocess, sys, time", + `subprocess.Popen([sys.executable, '-c', ${JSON.stringify(daemonScript)}])`, + "time.sleep(600)", + ].join("\n") + const wrapped = WindowsJobLauncher.wrap({ file: python, args: ["-c", supervisorScript] }) + const leader = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + let daemon = 0 + let daemonIdentity: string | undefined + try { + expect(wrapped.release).toBeTruthy() + await Bun.sleep(100) + expect(await Bun.file(marker).exists()).toBe(false) + expect( + await CredentialProcessLedger.register({ + id, + kind: "command", + pid: leader.pid!, + detached: true, + projectID, + windowsRelease: wrapped.release, + }), + ).toBe(true) + daemon = Number(await waitText(marker)) + daemonIdentity = await CredentialProcessLedger.identity(daemon) + expect(daemonIdentity).toMatch(/^[a-f0-9]{64}$/) + const ppid = Bun.spawn(["/bin/ps", "-o", "ppid=", "-p", String(daemon)], { stdout: "pipe" }) + expect(Number((await new Response(ppid.stdout).text()).trim())).toBe(1) + expect(await ppid.exited).toBe(0) + + expect(await CredentialProcessLedger.revoke({ id, kind: "command", projectID })).toBe(1) + expect(await CredentialProcessLedger.owns(daemon, daemonIdentity)).toBe(false) + } finally { + await CredentialProcessLedger.revoke({ id }).catch(() => undefined) + if (daemon && (await CredentialProcessLedger.owns(daemon, daemonIdentity))) process.kill(daemon, "SIGKILL") + if (leader.exitCode === null && leader.signalCode === null) leader.kill("SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } + }, + 15_000, +) diff --git a/backend/cli/test/file/publication.test.ts b/backend/cli/test/file/publication.test.ts index d2b6846b..aa9c98b5 100644 --- a/backend/cli/test/file/publication.test.ts +++ b/backend/cli/test/file/publication.test.ts @@ -2,10 +2,13 @@ import { $ } from "bun" import { describe, expect, test } from "bun:test" import fs from "node:fs/promises" import path from "node:path" +import { Bus } from "../../src/bus" import { PublicationFile } from "../../src/file/publication" import { PublicationReview } from "../../src/file/review" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { ProjectTrust } from "../../src/project/trust" +import { CommandRuntime } from "../../src/science/command/registry" +import { tmpdir, trustProject } from "../fixture/fixture" describe("PublicationFile", () => { test("detects real local publication export capabilities", async () => { @@ -77,6 +80,105 @@ describe("PublicationFile", () => { await expect(PublicationFile.render(tmp.path, { path: "report.md", format: "html" })).rejects.toThrow("escapes") }) + test("refuses an exports symlink instead of writing an HTML publication outside the project", async () => { + await using outside = await tmpdir() + await using tmp = await tmpdir({ + init: async (directory) => { + await Bun.write(path.join(directory, "report.md"), "# Confined result\n") + await fs.symlink(outside.path, path.join(directory, "exports")) + }, + }) + + await expect(PublicationFile.render(tmp.path, { path: "report.md", format: "html" })).rejects.toThrow("ambiguous") + expect(await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: outside.path }))).toEqual([]) + }) + + test("requires project trust before launching a tool-backed publication export", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + await Bun.write(path.join(directory, "report.md"), "# Untrusted result\n") + const bin = path.join(directory, "bin") + const marker = path.join(directory, "pandoc-ran") + await fs.mkdir(bin, { recursive: true }) + await Bun.write(path.join(bin, "pandoc"), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`) + await fs.chmod(path.join(bin, "pandoc"), 0o755) + return { bin, marker } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const prior = process.env.PATH + process.env.PATH = `${tmp.extra.bin}${path.delimiter}${prior ?? ""}` + try { + await expect(PublicationFile.render(tmp.path, { path: "report.md", format: "docx" })).rejects.toBeInstanceOf( + ProjectTrust.DeniedError, + ) + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + expect(CommandRuntime.list(Instance.project.id, "publication")).toEqual([]) + } finally { + process.env.PATH = prior + } + }, + }) + }) + + test("reaps a registered publication converter before trust revocation is acknowledged", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + await Bun.write(path.join(directory, "report.md"), "# Revocable export\n") + const bin = path.join(directory, "bin") + await fs.mkdir(bin, { recursive: true }) + await Bun.write( + path.join(bin, "pandoc"), + `#!/bin/sh +while true; do sleep 1; done +`, + ) + await fs.chmod(path.join(bin, "pandoc"), 0o755) + return bin + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const prior = process.env.PATH + process.env.PATH = `${tmp.extra}${path.delimiter}${prior ?? ""}` + const unsubscribe = Bus.subscribe(ProjectTrust.Event.Changed, async (event) => { + if (!event.properties.status.canExecuteProjectCode) { + await CommandRuntime.stopProject(Instance.project.id) + } + }) + try { + const pending = PublicationFile.render(tmp.path, { path: "report.md", format: "docx" }) + const outcome = pending.then( + () => undefined, + (error) => error as Error, + ) + await (async () => { + for (const _ of Array.from({ length: 200 })) { + if (CommandRuntime.list(Instance.project.id, "publication").length) return + await Bun.sleep(10) + } + throw new Error("Timed out waiting for the revocable Pandoc process") + })() + + const revoked = await ProjectTrust.update(Instance.project, { trusted: false }) + expect(revoked.state).toBe("revoked") + expect((await outcome)?.message).toContain("Pandoc exited") + expect(CommandRuntime.list(Instance.project.id, "publication")).toEqual([]) + expect(await Bun.file(path.join(tmp.path, "exports")).exists()).toBe(false) + } finally { + unsubscribe() + process.env.PATH = prior + } + }, + }) + }) + test("gates reviewed exports on a finalized report for the exact source bytes", async () => { await using tmp = await tmpdir({ git: true, @@ -157,12 +259,13 @@ describe("PublicationFile", () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const review = await PublicationReview.run({ path: "report.md", actor: "Reviewer" }) const finalized = await PublicationReview.finalize(review.id, { actor: "Aayam Bansal" }) const original = await Bun.file(path.join(tmp.path, "report.md")).text() const bin = path.join(tmp.path, "bin") - const ready = path.join(tmp.path, "pandoc-ready") const resume = path.join(tmp.path, "pandoc-resume") + const projectWrite = path.join(tmp.path, "converter-project-write") const pandoc = path.join(bin, "pandoc") await fs.mkdir(bin, { recursive: true }) await Bun.write( @@ -178,14 +281,17 @@ while [ "$#" -gt 0 ]; do fi shift done -printf ready > ${JSON.stringify(ready)} +if [ -n "$LAB_ACCESS_TOKEN" ]; then exit 91; fi +if printf escaped > ${JSON.stringify(projectWrite)}; then exit 92; fi while [ ! -f ${JSON.stringify(resume)} ]; do sleep 0.01; done cp "$source" "$output" `, ) await fs.chmod(pandoc, 0o755) const prior = process.env.PATH + const priorSecret = process.env.LAB_ACCESS_TOKEN process.env.PATH = `${bin}${path.delimiter}${prior ?? ""}` + process.env.LAB_ACCESS_TOKEN = "must-not-enter-publication-export" const pending = PublicationFile.render(tmp.path, { path: "report.md", format: "docx", @@ -193,19 +299,30 @@ cp "$source" "$output" review_id: finalized.id, }) try { - await (async () => { + const command = await (async () => { for (const _ of Array.from({ length: 200 })) { - if (await Bun.file(ready).exists()) return + const live = CommandRuntime.list(Instance.project.id, "publication")[0] + if (live) return live await Bun.sleep(10) } - throw new Error("Timed out waiting for the controlled Pandoc process") + throw new Error("Timed out waiting for Pandoc to enter the command ledger") })() + expect(command).toMatchObject({ + sessionID: "publication", + messageID: "publication", + state: "running", + process_id: expect.any(Number), + }) await Bun.write(path.join(tmp.path, "report.md"), "# Changed after validation\n") await Bun.write(resume, "resume") const result = await pending expect(await Bun.file(path.join(tmp.path, result.path)).text()).toBe(original) + expect(await Bun.file(projectWrite).exists()).toBe(false) + expect(CommandRuntime.list(Instance.project.id, "publication")).toEqual([]) } finally { process.env.PATH = prior + if (priorSecret === undefined) delete process.env.LAB_ACCESS_TOKEN + else process.env.LAB_ACCESS_TOKEN = priorSecret await Bun.write(resume, "resume") } }, diff --git a/backend/cli/test/file/ripgrep.test.ts b/backend/cli/test/file/ripgrep.test.ts new file mode 100644 index 00000000..67d6de60 --- /dev/null +++ b/backend/cli/test/file/ripgrep.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { Instance } from "../../src/project/instance" +import { FileRoutes } from "../../src/server/routes/file" +import { tmpdir } from "../fixture/fixture" + +test("file text search treats an untrusted pattern as data, never a shell command", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + await Bun.write(path.join(directory, "notes.txt"), "literal ; punctuation\nordinary needle\n") + return path.join(directory, "search-injected") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pattern = `needle\nprintf injected > ${JSON.stringify(tmp.extra)}` + const response = await FileRoutes().request(`/find?pattern=${encodeURIComponent(pattern)}`) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([]) + expect(await Bun.file(tmp.extra).exists()).toBe(false) + + const literal = await FileRoutes().request(`/find?pattern=${encodeURIComponent("literal ; punctuation")}`) + expect(literal.status).toBe(200) + expect((await literal.json()) as unknown[]).toHaveLength(1) + }, + }) +}) diff --git a/backend/cli/test/file/science-inspect.test.ts b/backend/cli/test/file/science-inspect.test.ts index 6da61ac0..070067cd 100644 --- a/backend/cli/test/file/science-inspect.test.ts +++ b/backend/cli/test/file/science-inspect.test.ts @@ -1,9 +1,36 @@ import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" import path from "node:path" import { File } from "../../src/file" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" +import { FileRoutes } from "../../src/server/routes/file" import { tmpdir } from "../fixture/fixture" +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitForExit(pid: number) { + for (let attempt = 0; attempt < 200; attempt++) { + if (!alive(pid)) return + await Bun.sleep(10) + } + throw new Error(`scientific preview descendant ${pid} remained alive`) +} + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) delete process.env[key] + else process.env[key] = value +} + describe("File.inspect", () => { test("recognizes H5AD containers and reports local inspection capabilities", async () => { await using tmp = await tmpdir({ @@ -29,6 +56,111 @@ describe("File.inspect", () => { }) }) + test.skipIf(!Bun.which("python3") && !Bun.which("python"))( + "an untrusted preview cannot import a project-controlled h5py module", + async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const marker = path.join(directory, "h5py-imported-before-trust") + const signature = Uint8Array.from([0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]) + await Bun.write(path.join(directory, "cells.h5ad"), signature) + await Bun.write( + path.join(directory, "h5py.py"), + `from pathlib import Path\nPath(${JSON.stringify(marker)}).write_text("executed")\n`, + ) + return { marker } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const previous = process.env.PYTHONPATH + process.env.PYTHONPATH = tmp.path + try { + const response = await FileRoutes().request("/file/inspect?path=cells.h5ad") + expect(response.status).toBe(200) + const result = (await response.json()) as Awaited> + expect(result).toMatchObject({ + signature: true, + tool: { name: "h5py", available: false }, + details: {}, + }) + expect(result.tool.detail).toContain("Trust this project") + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + } finally { + restoreEnv("PYTHONPATH", previous) + } + }, + }) + }, + ) + + test.skipIf(process.platform === "win32")( + "trusted inspection has a minimal environment and reaps background descendants", + async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const bin = path.join(directory, "preview-bin") + await fs.mkdir(bin, { recursive: true }) + const python = path.join(bin, "python3") + await Bun.write( + python, + `#!/bin/sh\nsleep 30 /dev/null 2>&1 &\npid=$!\nprintf '{"summary":{"secret":"%s","pythonpath":"%s","cwd":"%s","pid":%s}}' "\${OPENAI_API_KEY-unset}" "\${PYTHONPATH-unset}" "$PWD" "$pid"\n`, + ) + await fs.chmod(python, 0o755) + const signature = Uint8Array.from([0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]) + await Bun.write(path.join(directory, "cells.h5ad"), signature) + return { bin } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + const previous = { + PATH: process.env.PATH, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + PYTHONPATH: process.env.PYTHONPATH, + } + process.env.PATH = `${tmp.extra.bin}${path.delimiter}${previous.PATH ?? ""}` + process.env.OPENAI_API_KEY = "must-not-enter-preview" + process.env.PYTHONPATH = tmp.path + try { + const result = await File.inspect("cells.h5ad") + expect(result.tool).toMatchObject({ name: "h5py", available: true }) + const summary = result.details.summary as Record + expect(summary.secret).toBe("unset") + expect(summary.pythonpath).toBe("unset") + expect(summary.cwd).not.toBe(tmp.path) + const pid = Number(summary.pid) + expect(Number.isSafeInteger(pid)).toBe(true) + if (Sandbox.backend() !== "bubblewrap") await waitForExit(pid) + + const ledger = await Bun.file(CredentialProcessLedger.pathForTests()) + .json() + .catch(() => []) + expect( + (ledger as Array<{ kind?: string; project_id?: string }>).some( + (entry) => entry.kind === "command" && entry.project_id === Instance.project.id, + ), + ).toBe(false) + } finally { + restoreEnv("PATH", previous.PATH) + restoreEnv("OPENAI_API_KEY", previous.OPENAI_API_KEY) + restoreEnv("PYTHONPATH", previous.PYTHONPATH) + } + }, + }) + }, + 30_000, + ) + test("recognizes CRAM version bytes and adjacent indexes", async () => { await using tmp = await tmpdir({ init: async (directory) => { diff --git a/backend/cli/test/file/trash.test.ts b/backend/cli/test/file/trash.test.ts new file mode 100644 index 00000000..0ed77259 --- /dev/null +++ b/backend/cli/test/file/trash.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test" +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { FileTrash } from "../../src/file/trash" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { FileRoutes } from "../../src/server/routes/file" +import { executionSession, tmpdir } from "../fixture/fixture" + +describe("recoverable source file trash", () => { + test("retains approved bytes for 30 days and restores through the file route", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const target = path.join(tmp.path, "results", "finding.txt") + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, "approved finding\n", { mode: 0o640 }) + + const trashed = await FileTrash.trash({ + projectID: Instance.project.id, + sessionID: session.id, + path: target, + expectedContent: "approved finding\n", + }) + + expect(trashed).toMatchObject({ + originalPath: target, + filename: "finding.txt", + state: "trash", + size: 17, + mode: 0o640, + }) + expect(trashed.expiresAt - trashed.trashedAt).toBe(FileTrash.RETENTION_MS) + await expect(fs.readFile(target)).rejects.toThrow() + + const listed = await FileRoutes().request("/file/trash") + expect(listed.status).toBe(200) + expect(await listed.json()).toMatchObject([{ id: trashed.id, originalPath: target, state: "trash" }]) + + const restored = await FileRoutes().request(`/file/trash/${trashed.id}/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) + expect(restored.status).toBe(200) + expect(await restored.json()).toMatchObject({ id: trashed.id, state: "restored" }) + expect(await fs.readFile(target, "utf8")).toBe("approved finding\n") + if (process.platform !== "win32") expect((await fs.stat(target)).mode & 0o777).toBe(0o640) + expect(await FileTrash.list(Instance.project.id)).toEqual([]) + + const duplicate = await FileRoutes().request(`/file/trash/${trashed.id}/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) + expect(duplicate.status).toBe(404) + }, + }) + }) + + test("fails closed on changed bytes, symbolic links, and restore conflicts", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const target = path.join(tmp.path, "source.txt") + await fs.writeFile(target, "new bytes\n") + await expect( + FileTrash.trash({ + projectID: Instance.project.id, + sessionID: session.id, + path: target, + expectedContent: "approved bytes\n", + }), + ).rejects.toThrow("changed after approval") + expect(await fs.readFile(target, "utf8")).toBe("new bytes\n") + expect(await FileTrash.list(Instance.project.id)).toEqual([]) + + if (process.platform !== "win32") { + const linked = path.join(tmp.path, "linked.txt") + await fs.symlink(target, linked) + await expect( + FileTrash.trash({ projectID: Instance.project.id, sessionID: session.id, path: linked }), + ).rejects.toThrow("symbolic link") + expect(await fs.readlink(linked)).toBe(target) + expect(await fs.readFile(target, "utf8")).toBe("new bytes\n") + } + + const trashed = await FileTrash.trash({ + projectID: Instance.project.id, + sessionID: session.id, + path: target, + expectedContent: "new bytes\n", + }) + await fs.writeFile(target, "replacement must survive\n") + await expect( + FileTrash.restore({ projectID: Instance.project.id, sessionID: session.id, id: trashed.id }), + ).rejects.toThrow("Refusing to overwrite") + expect(await fs.readFile(target, "utf8")).toBe("replacement must survive\n") + expect(await FileTrash.list(Instance.project.id)).toMatchObject([{ id: trashed.id, state: "trash" }]) + }, + }) + }) + + test("purges expired recovery copies", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const target = path.join(tmp.path, "expired.txt") + await fs.writeFile(target, "expired\n") + const record = await FileTrash.trash({ + projectID: Instance.project.id, + sessionID: session.id, + path: target, + now: Date.now() - FileTrash.RETENTION_MS - 1, + }) + expect(record.expiresAt).toBeLessThan(Date.now()) + expect(await FileTrash.list(Instance.project.id)).toEqual([]) + expect( + await FileTrash.restore({ projectID: Instance.project.id, sessionID: session.id, id: record.id }), + ).toBeUndefined() + }, + }) + }) + + test("does not advertise metadata written before a recovery payload exists", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const id = `ftr_${crypto.randomUUID()}` + const projectID = Instance.project.id + const project = crypto.createHash("sha256").update(projectID).digest("hex") + const entry = path.join(Global.Path.data, "file-trash", project, id) + const now = Date.now() + await fs.mkdir(entry, { recursive: true }) + await fs.writeFile( + path.join(entry, "record.json"), + JSON.stringify({ + id, + projectID, + originalPath: path.join(tmp.path, "still-present.txt"), + filename: "still-present.txt", + size: 1, + sha256: "0".repeat(64), + mode: 0o600, + state: "trash", + trashedAt: now, + expiresAt: now + FileTrash.RETENTION_MS, + }), + ) + expect(await FileTrash.list(projectID)).toEqual([]) + await fs.rm(entry, { recursive: true, force: true }) + }, + }) + }) +}) diff --git a/backend/cli/test/fixture/authority-process.ts b/backend/cli/test/fixture/authority-process.ts new file mode 100644 index 00000000..10de2408 --- /dev/null +++ b/backend/cli/test/fixture/authority-process.ts @@ -0,0 +1,71 @@ +import { Storage } from "../../src/storage/storage" +import { AuthoritySignal } from "../../src/project/authority-signal" + +const [mode, arg] = process.argv.slice(2) + +async function within(promise: Promise, message: string) { + let timer: ReturnType | undefined + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), 5_000) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +if (mode === "init") { + await Storage.write(["interprocess", "counter"], { count: 0 }) +} else if (mode === "update") { + const iterations = Number(arg) + for (let index = 0; index < iterations; index++) { + await Storage.update<{ count: number }>(["interprocess", "counter"], (draft) => { + draft.count++ + }) + } +} else if (mode === "publish") { + await AuthoritySignal.publish({ kind: "trust", projectID: arg!, denied: true }) +} else if (mode === "watch") { + const [ready, result] = process.argv.slice(3) + let resolve!: () => void + const observed = new Promise((done) => { + resolve = done + }) + await using watcher = await AuthoritySignal.watch(async (change) => { + await Bun.write(result!, JSON.stringify(change)) + resolve() + }, 20) + await Bun.write(ready!, "ready") + await within(observed, "authority signal timeout") +} else if (mode === "watch-project") { + const [projectID, ready, result] = process.argv.slice(3) + let resolve!: () => void + const observed = new Promise((done) => { + resolve = done + }) + await using watcher = await AuthoritySignal.watch(async (change) => { + if (change.type !== "event" || change.event.kind !== "trust" || change.event.projectID !== projectID) { + return false + } + await Bun.write(result!, JSON.stringify(change)) + resolve() + return true + }, 20) + await Bun.write(ready!, "ready") + await within(observed, "authority signal timeout") +} else if (mode === "hold") { + const [ready, release] = process.argv.slice(3) + await AuthoritySignal.exclusive(async () => { + await Bun.write(ready!, "ready") + while (!(await Bun.file(release!).exists())) await Bun.sleep(10) + }) +} else if (mode === "acquire") { + await AuthoritySignal.exclusive(async () => { + await Bun.write(arg!, "acquired") + }) +} else { + throw new Error(`unknown mode: ${mode}`) +} diff --git a/backend/cli/test/fixture/authority-runtime-process.ts b/backend/cli/test/fixture/authority-runtime-process.ts new file mode 100644 index 00000000..0d044b43 --- /dev/null +++ b/backend/cli/test/fixture/authority-runtime-process.ts @@ -0,0 +1,338 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Pty } from "../../src/pty" +import { Session } from "../../src/session" +import { SessionFilesystem } from "../../src/session/filesystem" +import { NotebookTool } from "../../src/tool/biology/notebook" + +const [mode, directory, result, sessionID, grantID, shell, descendantFileArg] = process.argv.slice(2) + +const context = (id: string) => ({ + sessionID: id, + messageID: "message_authority_orphan", + callID: "call_authority_orphan", + agent: "biology", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, +}) + +async function waitText(file: string, attempt = 0): Promise { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value?.trim()) return value.trim() + if (attempt >= 300) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(20) + return waitText(file, attempt + 1) +} + +async function waitEscapedGroup(pid: number, leader: number, attempt = 0): Promise { + const proc = Bun.spawn(["/bin/ps", "-o", "pgid=", "-p", String(pid)], { + stdout: "pipe", + stderr: "ignore", + }) + const [code, output] = await Promise.all([proc.exited, new Response(proc.stdout).text()]) + const pgid = code === 0 ? Number(output.trim()) : 0 + if (pgid > 0 && pgid !== leader) return pgid + if (attempt >= 300) throw new Error(`Process ${pid} did not leave group ${leader}`) + await Bun.sleep(20) + return waitEscapedGroup(pid, leader, attempt + 1) +} + +async function processParent(pid: number): Promise { + const proc = Bun.spawn(["/bin/ps", "-o", "ppid=", "-p", String(pid)], { + stdout: "pipe", + stderr: "ignore", + }) + const [code, output] = await Promise.all([proc.exited, new Response(proc.stdout).text()]) + return code === 0 ? Number(output.trim()) : 0 +} + +async function ledger(kind: AuthorityProcessLedger.Kind) { + const entries = (await Bun.file(AuthorityProcessLedger.pathForTests()).json()) as Array<{ + kind: AuthorityProcessLedger.Kind + owner_pid: number + pid: number + identity: string + project_id: string + session_id: string + authority_generation: string + }> + const entry = entries.find((item) => item.kind === kind && item.owner_pid === process.pid) + if (!entry) throw new Error(`Missing ${kind} authority ledger entry for owner ${process.pid}`) + return entry +} + +await Instance.provide({ + directory, + ...(mode.startsWith("revoke-") ? { init: InstanceBootstrap } : {}), + fn: async () => { + if (mode === "setup" || mode === "setup-installation") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + const session = await Session.create({ title: "authority orphan" }) + const grant = await SessionFilesystem.grant({ + sessionID: session.id, + path: directory, + access: "read", + scope: mode === "setup-installation" ? "installation" : "session", + }) + const scratch = await SessionFilesystem.workspace(session.id) + const shellPath = path.join(scratch, "persistent-pty.sh") + const descendantFile = path.join(scratch, "authority-descendant.pid") + const python = Bun.which("python3") ?? "/usr/bin/python3" + const escaped = [ + "import os, signal, time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "os.fork() and os._exit(0)", + "os.setsid()", + "os.fork() and os._exit(0)", + `marker = open(${JSON.stringify(descendantFile)}, 'w')`, + "marker.write(str(os.getpid()))", + "marker.close()", + "time.sleep(3600)", + ].join("; ") + await Bun.write( + shellPath, + [ + "#!/bin/sh", + "trap '' HUP TERM INT", + `${JSON.stringify(python)} -c ${JSON.stringify(escaped)} &`, + 'child="$!"', + 'wait "$child"', + "while :; do sleep 1; done", + "", + ].join("\n"), + ) + await fs.chmod(shellPath, 0o700) + await Bun.write( + result, + JSON.stringify({ + projectID: Instance.project.id, + sessionID: session.id, + grantID: grant.id, + shell: shellPath, + descendantFile, + }), + ) + return + } + + if (mode === "owner-pty") { + const descendantFile = descendantFileArg + if (!descendantFile) throw new Error("Missing PTY descendant marker path") + process.env.SHELL = shell + await Pty.create({ sessionID, title: "orphan" }) + const entry = await ledger("pty") + const descendantPID = Number(await waitText(descendantFile)) + const descendantIdentity = await AuthorityProcessLedger.identity(descendantPID) + if (!descendantIdentity) throw new Error(`Missing PTY descendant identity for ${descendantPID}`) + const descendantGroup = await waitEscapedGroup(descendantPID, entry.pid) + await Bun.write( + result, + JSON.stringify({ + ...entry, + descendant: { + pid: descendantPID, + identity: descendantIdentity, + pgid: descendantGroup, + ppid: await processParent(descendantPID), + }, + }), + ) + await new Promise(() => {}) + return + } + + if (mode === "owner-biology") { + const descendantFile = descendantFileArg + if (!descendantFile) throw new Error("Missing biology descendant marker path") + const tool = await NotebookTool.init() + await tool.execute( + { + code: [ + "import subprocess, sys", + `descendant = subprocess.Popen([sys.executable, "-c", ${JSON.stringify( + [ + "import os, signal, time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "if os.fork(): os._exit(0)", + "os.setsid()", + "if os.fork(): os._exit(0)", + `marker = open(${JSON.stringify(descendantFile)}, 'w')`, + "marker.write(str(os.getpid()))", + "marker.close()", + "time.sleep(3600)", + ].join("\n"), + )}])`, + "print(descendant.pid)", + ].join("\n"), + timeout: 30_000, + }, + context(sessionID), + ) + const entry = await ledger("biology") + const descendantPID = Number(await waitText(descendantFile)) + const descendantIdentity = await AuthorityProcessLedger.identity(descendantPID) + if (!descendantIdentity) throw new Error(`Missing biology descendant identity for ${descendantPID}`) + const descendantGroup = await waitEscapedGroup(descendantPID, entry.pid) + await Bun.write( + result, + JSON.stringify({ + ...entry, + descendant: { + pid: descendantPID, + identity: descendantIdentity, + pgid: descendantGroup, + ppid: await processParent(descendantPID), + }, + }), + ) + await new Promise(() => {}) + return + } + + if (mode === "revoke-trust") { + await ProjectTrust.update(Instance.project, { trusted: false }) + return + } + if (mode === "revoke-filesystem") { + await SessionFilesystem.revoke(sessionID, grantID) + return + } + if (mode === "revoke-session") { + await Session.remove(sessionID) + return + } + if (mode === "reap") { + await AuthorityProcessLedger.revoke({ projectID: Instance.project.id }) + return + } + if (mode === "mismatched-identity") { + const child = Bun.spawn([process.execPath, "-e", "await new Promise(() => {})"], { + detached: process.platform !== "win32", + stdout: "ignore", + stderr: "ignore", + }) + const id = `identity-test-${crypto.randomUUID()}` + const original = await AuthorityProcessLedger.identity(child.pid) + if (!original) throw new Error("Could not capture fixture process identity") + try { + await AuthorityProcessLedger.register({ + id, + kind: "biology", + pid: child.pid, + projectID: Instance.project.id, + sessionID: "ses_identity_fixture", + authorityGeneration: "identity-fixture-generation", + }) + const entries = (await Bun.file(AuthorityProcessLedger.pathForTests()).json()) as Array<{ + id: string + identity: string + }> + const entry = entries.find((item) => item.id === id) + if (!entry) throw new Error("Missing identity fixture ledger entry") + entry.identity = "0".repeat(64) + await Bun.write(AuthorityProcessLedger.pathForTests(), JSON.stringify(entries)) + const killed = await AuthorityProcessLedger.revoke({ id }) + await Bun.write( + result, + JSON.stringify({ killed, survived: await AuthorityProcessLedger.owns(child.pid, original) }), + ) + } finally { + if (process.platform === "win32") child.kill("SIGKILL") + else process.kill(-child.pid, "SIGKILL") + await child.exited + } + return + } + if (mode === "non-group") { + const child = Bun.spawn([process.execPath, "-e", "await new Promise(() => {})"], { + stdout: "ignore", + stderr: "ignore", + }) + let error = "" + try { + await AuthorityProcessLedger.register({ + id: `group-test-${crypto.randomUUID()}`, + kind: "pty", + pid: child.pid, + projectID: Instance.project.id, + sessionID: "ses_group_fixture", + authorityGeneration: "group-fixture-generation", + }) + } catch (value) { + error = value instanceof Error ? value.message : String(value) + } finally { + child.kill("SIGKILL") + await child.exited + } + await Bun.write(result, JSON.stringify({ error })) + return + } + if (mode === "leader-exit-grandchild") { + const childFile = `${result}.child` + const releaseFile = `${result}.release` + const leader = Bun.spawn( + [ + process.execPath, + "-e", + [ + 'import fs from "node:fs/promises"', + "const [childFile, releaseFile] = process.argv.slice(1)", + "const child = Bun.spawn([process.execPath, '-e', `process.on('SIGHUP', () => {}); process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdout: 'ignore', stderr: 'ignore' })", + "child.unref()", + "await fs.writeFile(childFile, String(child.pid))", + "while (!(await fs.stat(releaseFile).then(() => true, () => false))) await Bun.sleep(10)", + ].join(";"), + childFile, + releaseFile, + ], + { detached: true, stdout: "ignore", stderr: "ignore" }, + ) + const id = `leader-exit-${crypto.randomUUID()}` + let childPID = 0 + try { + const registered = await AuthorityProcessLedger.register({ + id, + kind: "biology", + pid: leader.pid, + projectID: Instance.project.id, + sessionID: "ses_leader_exit_fixture", + authorityGeneration: "leader-exit-generation", + }) + if (!registered) throw new Error("Leader exited before registration") + childPID = Number(await waitText(childFile)) + const childIdentity = await AuthorityProcessLedger.identity(childPID) + if (!childIdentity) throw new Error(`Missing child identity for ${childPID}`) + await Bun.write(releaseFile, "release") + await leader.exited + const completed = await AuthorityProcessLedger.complete(id) + await Bun.write( + result, + JSON.stringify({ + completed, + child: { pid: childPID, identity: childIdentity }, + survived: await AuthorityProcessLedger.owns(childPID, childIdentity), + }), + ) + } finally { + if (childPID) { + const childIdentity = await AuthorityProcessLedger.identity(childPID) + if (childIdentity) process.kill(childPID, "SIGKILL") + } + await AuthorityProcessLedger.revoke({ id }).catch(() => undefined) + } + return + } + throw new Error(`Unknown authority runtime fixture mode: ${mode}`) + }, +}) diff --git a/backend/cli/test/fixture/dotenv-project-process.ts b/backend/cli/test/fixture/dotenv-project-process.ts new file mode 100644 index 00000000..46e45854 --- /dev/null +++ b/backend/cli/test/fixture/dotenv-project-process.ts @@ -0,0 +1,21 @@ +// Keep preload first: this reproduces the real CLI's earliest import boundary. +import "../../src/openscience/preload-env" +import { Instance } from "../../src/project/instance" +import { Plugin } from "../../src/plugin" + +const marker = process.argv[2]! +await Instance.provide({ + directory: process.cwd(), + init: Plugin.init, + fn: async () => { + process.stdout.write( + `${JSON.stringify({ + marker: await Bun.file(marker).exists(), + inline: process.env.OPENSCIENCE_CONFIG_CONTENT ?? null, + provider: process.env.OPENAI_API_KEY ?? null, + askpass: process.env.GIT_ASKPASS ?? null, + })}\n`, + ) + }, +}) +await Instance.disposeAll() diff --git a/backend/cli/test/fixture/kernel-built-in-setsid.ts b/backend/cli/test/fixture/kernel-built-in-setsid.ts new file mode 100644 index 00000000..770dc6e7 --- /dev/null +++ b/backend/cli/test/fixture/kernel-built-in-setsid.ts @@ -0,0 +1,109 @@ +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { KernelRuntime, type KernelIdentity } from "../../src/science/kernel/registry" +import { Session } from "../../src/session" +import "../../src/tool/notebook" +import "../../src/tool/rkernel" + +const [, , workspace, language, marker] = process.argv + +async function waitForMarker(attempt = 0): Promise { + const value = await Bun.file(marker) + .text() + .catch(() => "") + const pid = Number(value.trim()) + if (Number.isSafeInteger(pid) && pid > 0) return pid + if (attempt >= 500) throw new Error(`Timed out waiting for descendant marker ${marker}`) + await Bun.sleep(10) + return waitForMarker(attempt + 1) +} + +async function processRow(pid: number) { + const proc = Bun.spawn(["ps", "-o", "ppid=,pgid=", "-p", String(pid)], { + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Could not inspect descendant ${pid}: ${stderr.trim()}`) + const [ppid, pgid] = stdout.trim().split(/\s+/).map(Number) + return { ppid, pgid } +} + +await Instance.provide({ + directory: workspace, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + const session = await Session.create({}) + const identity: KernelIdentity = { + projectID: Instance.project.id, + sessionID: session.id, + name: `setsid-${language}`, + language, + } + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the setsid kernel regression") + const childCode = [ + "import os,time", + `open(${JSON.stringify(marker)}, "w").write(str(os.getpid()))`, + "time.sleep(600)", + ].join("; ") + const code = + language === "python" + ? [ + "import subprocess, sys", + `child = subprocess.Popen([sys.executable, "-c", ${JSON.stringify(childCode)}], start_new_session=True)`, + `open(${JSON.stringify(marker)}, "w").write(str(child.pid))`, + "child.pid", + ].join("\n") + : [ + "parallel::mcparallel({", + ` system2(${JSON.stringify(python)}, c("-c", shQuote(${JSON.stringify(`import os; os.setsid(); ${childCode}`)})), wait=TRUE, stdout=FALSE, stderr=FALSE)`, + "}, silent=TRUE)", + "TRUE", + ].join("\n") + let childPID = 0 + let childIdentity: string | undefined + try { + const execution = await KernelRuntime.execute(identity, code) + if (!execution.ok) throw new Error(`Could not launch ${language} descendant: ${execution.stderr}`) + childPID = await waitForMarker() + childIdentity = await AuthorityProcessLedger.identity(childPID) + if (!childIdentity) throw new Error(`Could not establish descendant identity for ${childPID}`) + const kernelPID = KernelRuntime.status(identity).process_id + if (!kernelPID) throw new Error(`${language} kernel did not publish its leader PID`) + const child = await processRow(childPID) + const ancestors: number[] = [] + let ancestor = child.ppid + for (let depth = 0; depth < 8 && ancestor > 0; depth++) { + ancestors.push(ancestor) + if (ancestor === kernelPID) break + ancestor = (await processRow(ancestor)).ppid + } + await KernelRuntime.release(identity) + console.log( + JSON.stringify({ + language, + kernelPID, + childPID, + childPPID: child.ppid, + childPGID: child.pgid, + childAncestors: ancestors, + survived: await AuthorityProcessLedger.owns(childPID, childIdentity), + }), + ) + } finally { + await KernelRuntime.release(identity).catch(() => undefined) + if (childPID && (await AuthorityProcessLedger.owns(childPID, childIdentity))) { + process.kill(childPID, "SIGKILL") + } + } + }, +}) diff --git a/backend/cli/test/fixture/kernel-leader-exit.ts b/backend/cli/test/fixture/kernel-leader-exit.ts new file mode 100644 index 00000000..bf14f5b7 --- /dev/null +++ b/backend/cli/test/fixture/kernel-leader-exit.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises" +import { spawn } from "node:child_process" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { KernelProcessIdentity } from "../../src/science/kernel/process" +import { KernelRuntime } from "../../src/science/kernel/registry" +import type { Kernel, KernelProcess, KernelStartOptions } from "../../src/science/kernel/types" +import { WindowsJobLauncher } from "../../src/process/windows-job-launcher" +import { Session } from "../../src/session" + +const [, , workspace, mode, sessionID = "", ready = "", childFile = "", releaseFile = ""] = process.argv + +const wait = async (file: string, attempt = 0): Promise => { + if (await Bun.file(file).exists()) return + if (attempt >= 500) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return wait(file, attempt + 1) +} + +await Instance.provide({ + directory: workspace, + fn: async () => { + if (mode === "setup") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + console.log((await Session.create({})).id) + return + } + + const kernels = new Map() + KernelRuntime.register({ + language: "leader-exit-test", + async get(id: string, options?: KernelStartOptions) { + const existing = kernels.get(id) + if (existing) return existing + const command = [ + "-e", + [ + 'import fs from "node:fs/promises"', + "const [childFile, releaseFile] = process.argv.slice(1)", + 'const child = Bun.spawn(["sleep", "30"], { stdout: "ignore", stderr: "ignore" })', + "child.unref()", + "await fs.writeFile(childFile, String(child.pid))", + "while (!(await Bun.file(releaseFile).exists())) await Bun.sleep(10)", + ].join(";"), + childFile, + releaseFile, + ] + const wrapped = WindowsJobLauncher.wrap({ file: process.execPath, args: command }) + const leader = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + const ownership = options?.processOwnership + ? { ...options.processOwnership, windowsRelease: wrapped.release } + : undefined + const identity = await KernelProcessIdentity.register(leader, ownership) + if (!identity) throw new Error("Kernel leader exited before registration") + await wait(childFile) + const kernel: Kernel = { + id, + language: "leader-exit-test", + ready: true, + process: identity, + async start() {}, + async execute() { + return { ok: true, outputs: [], stdout: "", stderr: "" } + }, + async shutdown() { + await KernelProcessIdentity.terminate(identity) + }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id: string) { + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + + if (mode === "owner") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + const kernel = await KernelRuntime.get({ + projectID: Instance.project.id, + sessionID, + name: "leader-exit", + language: "leader-exit-test", + }) + const processIdentity = kernel.process as KernelProcess + await fs.writeFile( + ready, + JSON.stringify({ + process: processIdentity, + childPID: Number(await fs.readFile(childFile, "utf8")), + }), + ) + await new Promise(() => {}) + } + + if (mode === "remove") await KernelRuntime.removeSession(Instance.project.id, sessionID) + }, +}) diff --git a/backend/cli/test/fixture/local-runtime-process.ts b/backend/cli/test/fixture/local-runtime-process.ts new file mode 100644 index 00000000..de922a55 --- /dev/null +++ b/backend/cli/test/fixture/local-runtime-process.ts @@ -0,0 +1,10 @@ +import fs from "node:fs" + +const [environment, pidfile] = process.argv.slice(2) +if (!environment || !pidfile) throw new Error("local runtime fixture requires environment and pid files") + +fs.writeFileSync(environment, JSON.stringify(process.env), { encoding: "utf8", mode: 0o600 }) +fs.writeFileSync(pidfile, String(process.pid), { encoding: "utf8", mode: 0o600 }) + +for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => process.exit(0)) +setInterval(() => {}, 1_000) diff --git a/backend/cli/test/fixture/mcp-descendant.mjs b/backend/cli/test/fixture/mcp-descendant.mjs new file mode 100644 index 00000000..f05a392b --- /dev/null +++ b/backend/cli/test/fixture/mcp-descendant.mjs @@ -0,0 +1,19 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { spawn } from "node:child_process" +import fs from "node:fs/promises" + +const marker = process.env.OPENSCIENCE_MCP_DESCENDANT_MARKER +if (!marker) throw new Error("Missing MCP descendant marker") +const escaped = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", +}) +escaped.unref() +await fs.writeFile(marker, String(escaped.pid)) + +const server = new McpServer({ name: "descendant-test", version: "1.0.0" }) +server.registerTool("alive", { description: "Keeps the fixture connected" }, async () => ({ + content: [{ type: "text", text: "ok" }], +})) +await server.connect(new StdioServerTransport()) diff --git a/backend/cli/test/fixture/ssh-compute-process.ts b/backend/cli/test/fixture/ssh-compute-process.ts new file mode 100644 index 00000000..2cea00d6 --- /dev/null +++ b/backend/cli/test/fixture/ssh-compute-process.ts @@ -0,0 +1,100 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { Config } from "../../src/config/config" +import { ComputeJobs } from "../../src/compute/jobs" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Session } from "../../src/session" + +const [mode, workspace, root, hostFile, sessionFile, jobFile] = process.argv.slice(2) +if (!mode || !workspace || !root || !hostFile || !sessionFile || !jobFile) + throw new Error("missing SSH fixture arguments") +const host = ComputeJobs.Host.parse(JSON.parse(await fs.readFile(hostFile, "utf8"))) + +await Config.setSandbox({ enabled: true, network: "deny", onUnavailable: "error" }) +await Instance.provide({ + directory: workspace, + fn: async () => { + const trust = await ProjectTrust.status(Instance.project) + if (!trust.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: trust.root }) + if ( + mode === "start" || + mode === "start-cancel" || + mode === "start-ignore-term" || + mode === "start-killpoint" || + mode === "start-double-fork" || + mode === "start-double-fork-cancel" + ) { + const session = await Session.create({}) + const request = { + sessionID: session.id, + name: "OpenSSH durable dispatch", + command: + mode === "start-ignore-term" + ? "trap '' TERM; printf 'cancel-ready\\n'; sleep 30" + : mode === "start-double-fork" + ? 'python3 -c \'import os,time,pathlib; p=os.fork(); p and os._exit(0); os.setsid(); p=os.fork(); p and os._exit(0); os.environ.clear(); time.sleep(1); pathlib.Path("outputs").mkdir(exist_ok=True); pathlib.Path("outputs/double-fork.txt").write_text("contained\\n")\'; printf \'leader-done\\n\'' + : mode === "start-double-fork-cancel" + ? "python3 -c 'import os,signal,time,pathlib; p=os.fork(); p and os._exit(0); os.setsid(); p=os.fork(); p and os._exit(0); os.environ.clear(); pathlib.Path(\"double-fork.pid\").write_text(str(os.getpid())); signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(30)'" + : mode === "start-cancel" + ? "printf 'cancel-ready\\n'; sleep 30" + : "mkdir -p outputs; printf 'remote:%s\\n' \"$(cat input.txt)\"; printf 'verified:%s\\n' \"$(cat input.txt)\" > outputs/result.txt; sleep 1", + target: { kind: "ssh" as const, host_id: host.id }, + uploads: ["input.txt"], + artifacts: + mode === "start-cancel" || mode === "start-ignore-term" || mode === "start-double-fork-cancel" + ? undefined + : ["outputs/*.txt"], + } + const plan = await ComputeJobs.plan(request, { root, workspace, hosts: [host] }) + if (plan.provider !== "ssh") throw new Error("expected SSH plan") + if (mode === "start-killpoint") process.env.OPENSCIENCE_SSH_TEST_KILLPOINT = "after-accept" + const job = await ComputeJobs.start( + { ...request, approval: plan.digest }, + { root, workspace, hosts: [host] }, + ).catch(async (error) => { + if (mode === "start-killpoint") { + delete process.env.OPENSCIENCE_SSH_TEST_KILLPOINT + const latest = (await ComputeJobs.list({ root, workspace, hosts: [host] })).at(-1) + if (latest) await Promise.all([fs.writeFile(sessionFile, session.id), fs.writeFile(jobFile, latest.id)]) + } + throw error + }) + await Promise.all([fs.writeFile(sessionFile, session.id), fs.writeFile(jobFile, job.id)]) + console.log(JSON.stringify({ id: job.id, remote_id: job.remote_id, fingerprint: job.ssh?.fingerprint })) + process.exit(0) + } + const id = (await fs.readFile(jobFile, "utf8")).trim() + if (mode === "cancel") { + const cancelled = await ComputeJobs.cancel(id, { root, workspace, hosts: [host] }) + console.log( + JSON.stringify({ + id: cancelled.id, + status: cancelled.status, + remote_id: cancelled.remote_id, + lifecycle: cancelled.lifecycle, + events: await ComputeJobs.events(id, { root, workspace, hosts: [host] }), + }), + ) + process.exit(0) + } + // Recovery crosses several real, host-key-pinned SSH control calls. Under + // the parallel backend suite those calls can exceed 20s even though the + // remote workload has finished, so keep the fixture poll inside the + // enclosing native integration budget rather than imposing a unit-test + // deadline here. + const finished = await ComputeJobs.wait(id, { root, workspace, hosts: [host], timeout: 60_000 }) + console.log( + JSON.stringify({ + id: finished.id, + status: finished.status, + remote_id: finished.remote_id, + lifecycle: finished.lifecycle, + artifacts: finished.artifacts, + log: await ComputeJobs.log(id, { root, workspace, hosts: [host] }), + events: await ComputeJobs.events(id, { root, workspace, hosts: [host] }), + }), + ) + process.exit(0) + }, +}) diff --git a/backend/cli/test/fixture/windows-job.ts b/backend/cli/test/fixture/windows-job.ts new file mode 100644 index 00000000..19c522ed --- /dev/null +++ b/backend/cli/test/fixture/windows-job.ts @@ -0,0 +1,5 @@ +import { WindowsJob } from "../../src/process/windows-job" + +const [action, name] = process.argv.slice(2) +if (action !== "terminate" || !name) throw new Error("usage: windows-job.ts terminate ") +process.exit(WindowsJob.terminate(name) ? 0 : 1) diff --git a/backend/cli/test/global/data-root.test.ts b/backend/cli/test/global/data-root.test.ts new file mode 100644 index 00000000..d61b8d1f --- /dev/null +++ b/backend/cli/test/global/data-root.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { DataRoot } from "@/global/data-root" +import { DataRootBarrier } from "@/global/data-root-barrier" +import { WindowsJunction } from "@/global/windows-junction" + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))) +}) + +async function root() { + const value = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-data-root-")) + roots.push(value) + return value +} + +describe("managed data root", () => { + test("Windows junction reparse buffer carries a mount-point tag and two UTF-16 paths", () => { + const target = "C:\\OpenScience Data" + const data = WindowsJunction.bufferForTests(target) + const substituteLength = data.readUInt16LE(10) + const printOffset = data.readUInt16LE(12) + const printLength = data.readUInt16LE(14) + expect(data.readUInt32LE(0)).toBe(WindowsJunction.IO_REPARSE_TAG_MOUNT_POINT) + expect(data.readUInt16LE(4)).toBe(data.length - 8) + expect(data.toString("utf16le", 16, 16 + substituteLength)).toBe(`\\??\\${target}`) + expect(data.toString("utf16le", 16 + printOffset, 16 + printOffset + printLength)).toBe(target) + }) + + test("switches every precomputed child path through one stable link", async () => { + const base = await root() + const config = path.join(base, "config") + const first = path.join(base, "first") + const second = path.join(base, "second") + await Promise.all([fs.mkdir(first), fs.mkdir(second)]) + const managed = await DataRoot.ensure(config, first, false) + const record = path.join(managed.path, "storage", "record.json") + await fs.mkdir(path.dirname(record), { recursive: true }) + await fs.writeFile(record, "first") + + await DataRoot.switchTo(managed.path, second) + await fs.mkdir(path.dirname(record), { recursive: true }) + await fs.writeFile(record, "second") + + expect(await fs.readFile(path.join(first, "storage", "record.json"), "utf8")).toBe("first") + expect(await fs.readFile(path.join(second, "storage", "record.json"), "utf8")).toBe("second") + expect(await fs.realpath(managed.path)).toBe(await fs.realpath(second)) + }) + + test("blocks new operations and drains existing operations before a switch", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const active = await DataRootBarrier.enter(path.join(managed.path, "record.json")) + let exclusive = false + const switching = DataRootBarrier.exclusive().then(async (lease) => { + exclusive = true + return lease + }) + await Bun.sleep(50) + expect(exclusive).toBe(false) + + await active[Symbol.asyncDispose]() + const lease = await switching + expect(exclusive).toBe(true) + let entered = false + const waiting = DataRootBarrier.enter(path.join(managed.path, "later.json")).then((value) => { + entered = true + return value + }) + await Bun.sleep(50) + expect(entered).toBe(false) + await lease[Symbol.asyncDispose]() + const later = await waiting + expect(entered).toBe(true) + await later[Symbol.asyncDispose]() + }) + + test("holds a request operation marker until its returned promise settles", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + let finish!: () => void + const delayed = new Promise((resolve) => (finish = resolve)) + const request = DataRootBarrier.during(managed.path, () => delayed) + await Bun.sleep(20) + + let exclusive = false + const switching = DataRootBarrier.exclusive().then((lease) => { + exclusive = true + return lease + }) + await Bun.sleep(50) + expect(exclusive).toBe(false) + + finish() + await request + const lease = await switching + expect(exclusive).toBe(true) + await lease[Symbol.asyncDispose]() + }) + + test.skipIf(process.platform === "win32")( + "keeps a reassigned child marker live after its owning server is SIGKILLed", + async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const ready = path.join(base, "ready.json") + const helper = path.join(base, "owner.ts") + const rootModule = new URL("../../src/global/data-root.ts", import.meta.url).href + const barrierModule = new URL("../../src/global/data-root-barrier.ts", import.meta.url).href + const identityModule = new URL("../../src/process/process-identity.ts", import.meta.url).href + await fs.writeFile( + helper, + [ + 'import { spawn } from "node:child_process"', + 'import fs from "node:fs/promises"', + `import { DataRoot } from ${JSON.stringify(rootModule)}`, + `import { DataRootBarrier } from ${JSON.stringify(barrierModule)}`, + `import { ProcessIdentity } from ${JSON.stringify(identityModule)}`, + "const [config, data, ready] = process.argv.slice(-3)", + "const managed = await DataRoot.ensure(config, data, false)", + "DataRootBarrier.configure({ root: managed.path, config })", + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" })', + "child.unref()", + "if (!child.pid) throw new Error('child PID missing')", + "const identity = await ProcessIdentity.capture(child.pid)", + "if (!identity) throw new Error('child identity missing')", + "const operation = await DataRootBarrier.enter(managed.path)", + "await operation.reassign({ pid: child.pid, identity })", + "await fs.writeFile(ready, JSON.stringify({ pid: child.pid, identity }))", + "await new Promise(() => undefined)", + ].join("\n"), + ) + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const owner = Bun.spawn([process.execPath, helper, config, data, ready], { + stdout: "ignore", + stderr: "pipe", + }) + let childPID: number | undefined + try { + const deadline = Date.now() + 10_000 + while (!(await Bun.file(ready).exists())) { + if (Date.now() >= deadline) throw new Error(await new Response(owner.stderr).text()) + await Bun.sleep(20) + } + childPID = ((await Bun.file(ready).json()) as { pid: number }).pid + process.kill(owner.pid, "SIGKILL") + await owner.exited + expect(() => process.kill(childPID!, 0)).not.toThrow() + + let exclusive = false + const switching = DataRootBarrier.exclusive(10_000).then((lease) => { + exclusive = true + return lease + }) + await Bun.sleep(100) + expect(exclusive).toBe(false) + process.kill(-childPID, "SIGKILL") + childPID = undefined + const lease = await switching + expect(exclusive).toBe(true) + await lease[Symbol.asyncDispose]() + } finally { + if (owner.exitCode === null) { + try { + process.kill(owner.pid, "SIGKILL") + } catch {} + } + if (childPID) { + try { + process.kill(-childPID, "SIGKILL") + } catch {} + } + } + }, + 20_000, + ) + + test.skipIf(process.platform !== "win32")( + "retargets the same managed junction repeatedly on Windows without deleting its name", + async () => { + const base = await root() + const config = path.join(base, "config") + const first = path.join(base, "first") + const second = path.join(base, "second") + await Promise.all([fs.mkdir(first), fs.mkdir(second)]) + const managed = await DataRoot.ensure(config, first, false) + const identity = (await fs.lstat(managed.path)).ino + const canonicalFirst = await fs.realpath(first) + const canonicalSecond = await fs.realpath(second) + + let reading = true + const failures: unknown[] = [] + const reader = (async () => { + while (reading) { + const selected = await fs.realpath(managed.path).catch((error) => { + failures.push(error) + return undefined + }) + if (selected !== undefined && selected !== canonicalFirst && selected !== canonicalSecond) { + failures.push(selected) + } + await Bun.sleep(0) + } + })() + + try { + for (let attempt = 0; attempt < 50; attempt++) { + await DataRoot.switchTo(managed.path, attempt % 2 ? first : second) + } + } finally { + reading = false + await reader + } + + expect(failures).toEqual([]) + expect((await fs.lstat(managed.path)).ino).toBe(identity) + expect(await fs.realpath(managed.path)).toBe(canonicalFirst) + }, + 20_000, + ) +}) diff --git a/backend/cli/test/installation/native-package-matrix.test.ts b/backend/cli/test/installation/native-package-matrix.test.ts index b74a2d96..e1bff7d2 100644 --- a/backend/cli/test/installation/native-package-matrix.test.ts +++ b/backend/cli/test/installation/native-package-matrix.test.ts @@ -25,6 +25,8 @@ async function pack(dir: string, output: string) { return path.join(output, file) } +// This exercises seven real, sequential npm resolver installs. Their combined +// runtime legitimately exceeds Bun's 5s unit-test default on a loaded runner. test("npm selects every supported native package contract with lifecycle scripts disabled", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-native-matrix-")) const source = path.join(root, "packages") @@ -148,4 +150,4 @@ test("npm selects every supported native package contract with lifecycle scripts } finally { await fs.rm(root, { recursive: true, force: true }) } -}) +}, 30_000) diff --git a/backend/cli/test/installation/root-isolation.test.ts b/backend/cli/test/installation/root-isolation.test.ts index 58065d28..bba2391c 100644 --- a/backend/cli/test/installation/root-isolation.test.ts +++ b/backend/cli/test/installation/root-isolation.test.ts @@ -85,11 +85,11 @@ describe("isolated config and data roots", () => { `Platform package: @synsci/openscience-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`, ) expect(stdout).toContain(`Config root: ${config}`) - expect(stdout).toContain(`Data root: ${data}`) + expect(stdout).toContain(`Data root: ${await fs.realpath(data)}`) expect(stdout).toContain(`Cache root: ${path.join(scope, "cache", "openscience")}`) expect(stdout).toContain(`State root: ${path.join(scope, "state", "openscience")}`) expect(await tree(outside)).toEqual(before) - expect(await fs.readdir(config)).toEqual(["openscience.json"]) + expect((await fs.readdir(config)).toSorted()).toEqual(["data-root-operations", "openscience.json"]) expect(await fs.stat(data).then((stat) => stat.isDirectory())).toBe(true) } finally { await Promise.all([ diff --git a/backend/cli/test/installation/update-safety.test.ts b/backend/cli/test/installation/update-safety.test.ts new file mode 100644 index 00000000..906c3269 --- /dev/null +++ b/backend/cli/test/installation/update-safety.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Installation } from "../../src/installation" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +describe("Installation update safety", () => { + const fetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = fetch + }) + + test("detects the install method from immutable executable paths without running project package configuration", () => { + expect( + Installation.methodFromPaths({ + execPath: "/opt/homebrew/bin/node", + scriptPath: "/opt/homebrew/lib/node_modules/@synsci/openscience/bin/openscience", + }), + ).toBe("npm") + expect( + Installation.methodFromPaths({ + execPath: "/Users/researcher/.bun/bin/bun", + scriptPath: "/Users/researcher/.bun/install/global/node_modules/@synsci/openscience/bin/openscience", + }), + ).toBe("bun") + expect( + Installation.methodFromPaths({ + execPath: + "/opt/homebrew/lib/node_modules/@synsci/openscience/node_modules/@synsci/openscience-darwin-arm64/bin/openscience", + }), + ).toBe("npm") + expect( + Installation.methodFromPaths({ + execPath: "/Users/researcher/project/malicious-bin/node", + scriptPath: "/Users/researcher/project/openscience.ts", + }), + ).toBe("unknown") + }) + + test("always checks npm releases through the fixed public registry", async () => { + const urls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + urls.push(String(input)) + expect(init?.signal).toBeDefined() + return Response.json({ version: "9.9.9" }) + }) as typeof globalThis.fetch + + expect(await Installation.latest("npm")).toBe("9.9.9") + expect(urls).toEqual([`https://registry.npmjs.org/@synsci/openscience/${Installation.npmReleaseChannel()}`]) + }) + + test("runs an explicit package-manager upgrade outside the project with a narrow environment", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-upgrade-safety-")) + const bin = path.join(root, "bin") + const output = path.join(root, "probe.txt") + const runner = path.join(root, "upgrade.ts") + const installation = new URL("../../src/installation/index.ts", import.meta.url).href + await fs.mkdir(bin) + await fs.writeFile(path.join(bin, "npm"), `#!/bin/sh\npwd > '${output}'\nenv >> '${output}'\n`, { mode: 0o755 }) + await fs.writeFile( + runner, + `import { Installation } from ${JSON.stringify(installation)}\nawait Installation.upgrade("npm", "9.9.9")\n`, + ) + + try { + const proc = Bun.spawn([process.execPath, runner], { + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + OPENSCIENCE_UNTRUSTED_SENTINEL: "must-not-leak", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [code, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + expect(code, error).toBe(0) + const lines = (await fs.readFile(output, "utf8")).split("\n") + expect(lines[0]).toStartWith(path.join(os.tmpdir(), "openscience-upgrade-")) + expect(lines[0]).not.toBe(process.cwd()) + expect(lines.some((line) => line.includes("OPENSCIENCE_UNTRUSTED_SENTINEL"))).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/backend/cli/test/lsp/client.test.ts b/backend/cli/test/lsp/client.test.ts index c2ba3ac5..e9ac4ba4 100644 --- a/backend/cli/test/lsp/client.test.ts +++ b/backend/cli/test/lsp/client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, beforeEach } from "bun:test" +import { spawn } from "node:child_process" import path from "path" import { LSPClient } from "../../src/lsp/client" import { LSPServer } from "../../src/lsp/server" @@ -7,7 +8,6 @@ import { Log } from "../../src/util/log" // Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { - const { spawn } = require("child_process") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") return { process: spawn(process.execPath, [serverPath], { @@ -16,6 +16,14 @@ function spawnFakeServer() { } } +function spawnScript(script: string) { + return { + process: spawn(process.execPath, ["-e", script], { + stdio: "pipe", + }), + } +} + describe("LSPClient interop", () => { beforeEach(async () => { await Log.init({ print: true }) @@ -92,4 +100,48 @@ describe("LSPClient interop", () => { await client.shutdown() }) + + test("fails promptly when the server exits during initialization", async () => { + const handle = spawnScript("process.exit(17)") as unknown as LSPServer.Handle + const started = Date.now() + + await expect( + Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "dead", + server: handle, + root: process.cwd(), + initializationTimeoutMs: 5_000, + }), + }), + ).rejects.toThrow("LSPInitializeError") + + expect(Date.now() - started).toBeLessThan(1_000) + }) + + test("bounds initialization when a live server never responds", async () => { + const handle = spawnScript("process.stdin.resume()") as unknown as LSPServer.Handle + const started = Date.now() + + try { + await expect( + Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "blocked", + server: handle, + root: process.cwd(), + initializationTimeoutMs: 50, + }), + }), + ).rejects.toThrow("LSPInitializeError") + + expect(Date.now() - started).toBeLessThan(1_000) + } finally { + handle.process.kill() + } + }) }) diff --git a/backend/cli/test/lsp/environment.test.ts b/backend/cli/test/lsp/environment.test.ts new file mode 100644 index 00000000..f22c53ed --- /dev/null +++ b/backend/cli/test/lsp/environment.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +function quote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +test("language-server children never inherit host credentials", async () => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "host-bin") + const project = path.join(tmp.path, "project") + const marker = path.join(project, "lsp-environment") + const source = path.join(project, "probe.rb") + const server = path.join(bin, "rubocop") + const sourceFixture = path.join(import.meta.dir, "..", "fixture", "lsp", "fake-lsp-server.js") + const fixture = path.join(bin, "fake-lsp-server.js") + await fs.mkdir(bin, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.copyFile(sourceFixture, fixture) + await Bun.write(path.join(project, "Gemfile"), 'source "https://rubygems.org"\n') + await Bun.write(source, "puts :ok\n") + await Bun.write( + server, + `#!/bin/sh\nprintf '%s|%s|%s' "\${AWS_SECRET_ACCESS_KEY:-absent}" "\${OPENAI_API_KEY:-absent}" "\${LAB_ACCESS_TOKEN:-absent}" > ${quote(marker)}\nexec ${quote(process.execPath)} ${quote(fixture)} "$@"\n`, + ) + await fs.chmod(server, 0o700) + + const saved = { + PATH: process.env.PATH, + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + LAB_ACCESS_TOKEN: process.env.LAB_ACCESS_TOKEN, + } + process.env.PATH = `${bin}${path.delimiter}${saved.PATH ?? ""}` + process.env.AWS_SECRET_ACCESS_KEY = "aws-host-secret" + process.env.OPENAI_API_KEY = "provider-host-secret" + process.env.LAB_ACCESS_TOKEN = "settings-host-secret" + try { + await Instance.provide({ + directory: project, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + await LSP.touchFile(source) + for (let attempt = 0; attempt < 50 && !(await Bun.file(marker).exists()); attempt++) await Bun.sleep(10) + if (Sandbox.available()) expect(await Bun.file(marker).text()).toBe("absent|absent|absent") + else expect(await Bun.file(marker).exists()).toBe(false) + await LSP.dispose() + }, + }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await Instance.disposeAll() + } +}) diff --git a/backend/cli/test/lsp/orphan-process.test.ts b/backend/cli/test/lsp/orphan-process.test.ts new file mode 100644 index 00000000..2e379506 --- /dev/null +++ b/backend/cli/test/lsp/orphan-process.test.ts @@ -0,0 +1,257 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" + +const posixTest = process.platform === "win32" ? test.skip : test +const cwd = path.resolve(import.meta.dir, "../..") +type PipedProcess = Omit, "stdout" | "stderr"> & { + stdout: ReadableStream> + stderr: ReadableStream> +} + +function environment(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + // The process-group contract is independent of the OS sandbox. Disabling + // it here also avoids Linux bubblewrap's stronger --die-with-parent + // behavior masking the dead-owner recovery path this test exercises. + OPENSCIENCE_CONFIG_CONTENT: JSON.stringify({ sandbox: { enabled: false } }), + } +} + +async function readJsonLine(process: PipedProcess, label: string): Promise { + return new Promise((resolve, reject) => { + let buffered = "" + const timeout = setTimeout(() => reject(new Error(`${label} did not report durable ownership`)), 20_000) + const reader = process.stdout.getReader() + void (async () => { + const decoder = new TextDecoder() + while (true) { + const chunk = await reader.read() + if (chunk.done) throw new Error(`${label} stdout closed before durable ownership was reported`) + buffered += decoder.decode(chunk.value, { stream: true }) + const line = buffered.split("\n").find((value) => value.trim().startsWith("{")) + if (!line) continue + clearTimeout(timeout) + resolve(JSON.parse(line) as T) + return + } + })().catch((error) => { + clearTimeout(timeout) + reject(error) + }) + process.exited.then(async (code) => { + if (code === 0) return + const stderr = await new Response(process.stderr).text() + clearTimeout(timeout) + reject(new Error(`${label} exited ${code}: ${stderr}`)) + }) + }) +} + +async function run(process: PipedProcess, label: string) { + const [code, stderr] = await Promise.all([process.exited, new Response(process.stderr).text()]) + if (code !== 0) throw new Error(`${label} exited ${code}: ${stderr}`) +} + +async function processGroup(pid: number): Promise { + const process = Bun.spawn(["/bin/ps", "-o", "pgid=", "-p", String(pid)], { + stdout: "pipe", + stderr: "pipe", + }) + const [code, output, error] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]) + if (code !== 0) throw new Error(`Could not inspect process group for ${pid}: ${error}`) + return Number(output.trim()) +} + +posixTest( + "fresh-process trust revocation reaps an orphaned LSP and its direct setsid descendant", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-lsp-orphan-")) + const workspace = path.join(root, "workspace") + const runner = path.join(root, "runner.ts") + const wrapper = path.join(workspace, "orphan-lsp") + const server = path.join(workspace, "fake-lsp-server.js") + const source = path.join(workspace, "probe.orphan") + const descendantFile = path.join(workspace, "descendant.pid") + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const bootstrap = new URL("../../src/project/bootstrap.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const lsp = new URL("../../src/lsp/index.ts", import.meta.url).href + const ledger = new URL("../../src/credentials/process-ledger.ts", import.meta.url).href + const python = Bun.which("python3") ?? "/usr/bin/python3" + await fs.mkdir(workspace, { recursive: true }) + const fake = await fs.readFile(path.join(import.meta.dir, "../fixture/lsp/fake-lsp-server.js"), "utf8") + await Bun.write(server, `${fake}\nsetInterval(() => {}, 1000)\n`) + await Bun.write( + wrapper, + [ + "#!/bin/sh", + "trap '' HUP TERM INT", + `${JSON.stringify(python)} -c ${JSON.stringify( + [ + "import os, signal, time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "os.setsid()", + `open(${JSON.stringify(descendantFile)}, 'w').write(str(os.getpid()))`, + "time.sleep(600)", + ].join("; "), + )} &`, + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(server)}`, + "", + ].join("\n"), + ) + await fs.chmod(wrapper, 0o700) + await Bun.write(source, "orphan\n") + await Bun.write( + path.join(workspace, "openscience.json"), + JSON.stringify({ + lsp: { + orphan: { + command: [wrapper], + extensions: [".orphan"], + }, + }, + }), + ) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { Instance } from ${JSON.stringify(instance)} +import { InstanceBootstrap } from ${JSON.stringify(bootstrap)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { LSP } from ${JSON.stringify(lsp)} +import { CredentialProcessLedger } from ${JSON.stringify(ledger)} + +const [mode, workspace, source, descendantFile] = process.argv.slice(2) +async function waitText(file, attempt = 0) { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value?.trim()) return value.trim() + if (attempt >= 500) throw new Error("Timed out waiting for LSP descendant") + await Bun.sleep(20) + return waitText(file, attempt + 1) +} + +if (mode === "owner") { + await Instance.provide({ + directory: workspace, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + }, + }) + await Instance.disposeAll() + await Instance.provide({ + directory: workspace, + init: InstanceBootstrap, + fn: async () => { + await LSP.touchFile(source) + const entries = await Bun.file(CredentialProcessLedger.pathForTests()).json() + const entry = entries.find((item) => item.kind === "lsp" && item.project_id === Instance.project.id) + if (!entry) throw new Error("Missing durable LSP process entry") + const descendantPID = Number(await waitText(descendantFile)) + const descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + if (!descendantIdentity) throw new Error("Missing LSP descendant identity") + console.log(JSON.stringify({ + projectID: Instance.project.id, + pid: entry.pid, + identity: entry.identity, + descendant: { pid: descendantPID, identity: descendantIdentity }, + })) + await new Promise(() => {}) + }, + }) +} else if (mode === "revoke") { + await Instance.provide({ + directory: workspace, + init: InstanceBootstrap, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + }, + }) +} else if (mode === "reap") { + await Instance.provide({ + directory: workspace, + fn: async () => { + await CredentialProcessLedger.revoke({ kind: "lsp", projectID: Instance.project.id }) + }, + }) +} else { + throw new Error("Unknown LSP orphan fixture mode") +} +`, + ) + + const spawn = (mode: "owner" | "revoke" | "reap") => + Bun.spawn([process.execPath, runner, mode, workspace, source, descendantFile], { + cwd, + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }) as PipedProcess + + let owner: PipedProcess | undefined + let entry: + | { + projectID: string + pid: number + identity: string + descendant: { pid: number; identity: string } + } + | undefined + try { + owner = spawn("owner") + const registered = await readJsonLine>(owner, "LSP owner") + entry = registered + expect(await CredentialProcessLedger.owns(registered.pid, registered.identity)).toBe(true) + expect(await CredentialProcessLedger.owns(registered.descendant.pid, registered.descendant.identity)).toBe(true) + expect(await processGroup(registered.descendant.pid)).toBe(registered.descendant.pid) + expect(await processGroup(registered.descendant.pid)).not.toBe(registered.pid) + + owner.kill("SIGKILL") + await owner.exited + await Bun.sleep(100) + // macOS responsibility supervision observes the exact server identity + // and reaps its tree immediately on owner death. Other POSIX platforms + // retain the orphan for the fresh-server revocation path below. + const survivesOwner = process.platform !== "darwin" + expect(await CredentialProcessLedger.owns(registered.pid, registered.identity)).toBe(survivesOwner) + expect(await CredentialProcessLedger.owns(registered.descendant.pid, registered.descendant.identity)).toBe( + survivesOwner, + ) + + await run(spawn("revoke"), "fresh LSP trust revoker") + expect(await CredentialProcessLedger.owns(registered.pid, registered.identity)).toBe(false) + expect(await CredentialProcessLedger.owns(registered.descendant.pid, registered.descendant.identity)).toBe(false) + expect(await Bun.file(path.join(root, "data", "credential-processes.json")).json()).toEqual([]) + } finally { + owner?.kill("SIGKILL") + await run(spawn("reap"), "LSP orphan cleanup").catch(() => undefined) + if (entry && (await CredentialProcessLedger.owns(entry.pid, entry.identity))) { + process.kill(entry.pid, "SIGKILL") + } + if (entry && (await CredentialProcessLedger.owns(entry.descendant.pid, entry.descendant.identity))) { + process.kill(entry.descendant.pid, "SIGKILL") + } + await fs.rm(root, { recursive: true, force: true }) + } + }, + 60_000, +) diff --git a/backend/cli/test/lsp/sandbox.test.ts b/backend/cli/test/lsp/sandbox.test.ts new file mode 100644 index 00000000..cd07065c --- /dev/null +++ b/backend/cli/test/lsp/sandbox.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Config } from "../../src/config/config" +import { LSP } from "../../src/lsp" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +function quote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +test("hostile project LSP config cannot weaken the global sandbox or inherit a host secret", async () => { + await using tmp = await tmpdir() + const project = path.join(tmp.path, "project") + const escaped = path.join(tmp.path, "escaped") + const environment = path.join(project, "environment") + const pidFile = path.join(project, "pid") + const source = path.join(project, "probe.hostile") + const server = path.join(project, "hostile-lsp") + const fixture = path.join(project, "fake-lsp-server.js") + await fs.mkdir(project, { recursive: true }) + await fs.copyFile(path.join(import.meta.dir, "..", "fixture", "lsp", "fake-lsp-server.js"), fixture) + await Bun.write(source, "hostile\n") + await Bun.write( + server, + `#!/bin/sh +printf '%s|%s' "\${OPENAI_API_KEY:-absent}" "\${LSP_HOST_SECRET:-absent}" > ${quote(environment)} +printf escaped > ${quote(escaped)} +printf '%s' "$$" > ${quote(pidFile)} +exec ${quote(process.execPath)} ${quote(fixture)} "$@" +`, + ) + await fs.chmod(server, 0o700) + + const previous = process.env.LSP_HOST_SECRET + process.env.LSP_HOST_SECRET = "host-only-secret" + await Bun.write( + path.join(project, "openscience.json"), + JSON.stringify({ + // A repository cannot turn off or widen the machine-wide boundary. + sandbox: { enabled: false, network: "allow", allowWrite: [tmp.path], onUnavailable: "allow" }, + lsp: { + hostile: { + command: [server], + extensions: [".hostile"], + env: { + OPENAI_API_KEY: "{env:LSP_HOST_SECRET}", + LSP_HOST_SECRET: "{env:LSP_HOST_SECRET}", + }, + }, + }, + }), + ) + + try { + await Instance.provide({ + directory: project, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) + await Instance.disposeAll() + + await Instance.provide({ + directory: project, + init: InstanceBootstrap, + fn: async () => { + expect((await Config.trustedSandbox()).enabled).toBe(true) + await LSP.touchFile(source) + if (!Sandbox.available()) { + expect(await Bun.file(environment).exists()).toBe(false) + expect(await Bun.file(escaped).exists()).toBe(false) + await ProjectTrust.update(Instance.project, { trusted: false }) + return + } + expect(await Bun.file(environment).text()).toBe("absent|absent") + expect(await Bun.file(escaped).exists()).toBe(false) + + const pid = Number(await Bun.file(pidFile).text()) + expect(alive(pid)).toBe(true) + await ProjectTrust.update(Instance.project, { trusted: false }) + for (let attempt = 0; attempt < 100 && alive(pid); attempt++) await Bun.sleep(10) + expect(alive(pid)).toBe(false) + }, + }) + } finally { + if (previous === undefined) delete process.env.LSP_HOST_SECRET + else process.env.LSP_HOST_SECRET = previous + await Instance.disposeAll() + } +}) diff --git a/backend/cli/test/lsp/trust.test.ts b/backend/cli/test/lsp/trust.test.ts new file mode 100644 index 00000000..1293e019 --- /dev/null +++ b/backend/cli/test/lsp/trust.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" +import { tmpdir } from "../fixture/fixture" + +function quote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +test("globally installed language servers cannot start in an untrusted project", async () => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "host-bin") + const project = path.join(tmp.path, "project") + const marker = path.join(project, "lsp-started") + const escaped = path.join(tmp.path, "lsp-escaped") + const source = path.join(project, "probe.rb") + const server = path.join(bin, "rubocop") + const sourceFixture = path.join(import.meta.dir, "..", "fixture", "lsp", "fake-lsp-server.js") + const fixture = path.join(bin, "fake-lsp-server.js") + await fs.mkdir(bin, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.copyFile(sourceFixture, fixture) + await Bun.write(path.join(project, "Gemfile"), 'source "https://rubygems.org"\n') + await Bun.write(source, "puts :ok\n") + await Bun.write( + server, + `#!/bin/sh\nprintf escaped > ${quote(escaped)}\nprintf started > ${quote(marker)}\nexec ${quote(process.execPath)} ${quote(fixture)} "$@"\n`, + ) + await fs.chmod(server, 0o700) + + const original = process.env.PATH + process.env.PATH = `${bin}${path.delimiter}${original ?? ""}` + try { + await Instance.provide({ + directory: project, + fn: async () => { + expect((await ProjectTrust.status(Instance.project)).canExecuteProjectCode).toBe(false) + await LSP.touchFile(source) + await Bun.sleep(50) + expect(await Bun.file(marker).exists()).toBe(false) + + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + await LSP.touchFile(source) + expect(await Bun.file(marker).exists()).toBe(Sandbox.available()) + expect(await Bun.file(escaped).exists()).toBe(false) + await LSP.dispose() + }, + }) + } finally { + process.env.PATH = original + await Instance.disposeAll() + } +}) diff --git a/backend/cli/test/mcp/inspect.test.ts b/backend/cli/test/mcp/inspect.test.ts index 0d19f630..de086c1d 100644 --- a/backend/cli/test/mcp/inspect.test.ts +++ b/backend/cli/test/mcp/inspect.test.ts @@ -52,7 +52,7 @@ process.exit(0) expect(exit, error).toBe(0) const detail = JSON.parse(output) - expect(detail.status.status).toBe("connected") + expect(detail.status.status, `${JSON.stringify(detail)}\n${error}`).toBe("connected") expect(detail.auth).toBeUndefined() expect(detail.tools).toEqual([{ name: "echo", description: "Echo a value" }]) expect(detail.resources).toEqual([ @@ -66,3 +66,74 @@ process.exit(0) expect(detail.prompts).toEqual([{ name: "review", description: "Review a result" }]) expect(detail.errors).toEqual({}) }) + +const posixTest = process.platform === "win32" ? test.skip : test + +posixTest("local MCP disposal reaps a direct child that starts a new session", async () => { + await using tmp = await tmpdir() + const runner = `${tmp.path}/dispose-descendant.ts` + const marker = `${tmp.path}/mcp-descendant.pid` + const server = new URL("../fixture/mcp-descendant.mjs", import.meta.url).pathname + + await Bun.write( + `${tmp.path}/openscience.json`, + JSON.stringify({ + mcp: { + descendant: { + type: "local", + command: [process.execPath, server], + environment: { OPENSCIENCE_MCP_DESCENDANT_MARKER: marker }, + }, + }, + }), + ) + + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { MCP } from ${JSON.stringify(new URL("../../src/mcp/index.ts", import.meta.url).href)} +import { CredentialProcessLedger } from ${JSON.stringify(new URL("../../src/credentials/process-ledger.ts", import.meta.url).href)} +import { Instance } from ${JSON.stringify(new URL("../../src/project/instance.ts", import.meta.url).href)} +import { ProjectTrust } from ${JSON.stringify(new URL("../../src/project/trust.ts", import.meta.url).href)} + +const result = await Instance.provide({ + directory: process.argv[2], + fn: async () => { + const trust = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: trust.root }) + const detail = await MCP.inspect("descendant") + if (detail.status.status !== "connected") throw new Error(JSON.stringify(detail)) + let pid = 0 + for (let attempt = 0; attempt < 200; attempt++) { + pid = Number((await fs.readFile(process.argv[3], "utf8").catch(() => "0")).trim()) + if (pid) break + await Bun.sleep(10) + } + if (!pid) throw new Error("MCP descendant did not report its PID") + const identity = await CredentialProcessLedger.identity(pid) + if (!identity) throw new Error("MCP descendant had no process identity") + await MCP.disposeLocal() + const survived = await CredentialProcessLedger.owns(pid, identity) + if (survived) process.kill(pid, "SIGKILL") + return { pid, survived } + }, +}) +process.stdout.write(JSON.stringify(result)) +process.exit(0) +`, + ) + + const proc = spawn([process.execPath, runner, tmp.path, marker], { + cwd: tmp.path, + stdout: "pipe", + stderr: "pipe", + }) + const [output, error, exit] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + expect(exit, error).toBe(0) + expect(JSON.parse(output)).toMatchObject({ pid: expect.any(Number), survived: false }) +}) diff --git a/backend/cli/test/openscience-env.test.ts b/backend/cli/test/openscience-env.test.ts index 7977864d..1dec634b 100644 --- a/backend/cli/test/openscience-env.test.ts +++ b/backend/cli/test/openscience-env.test.ts @@ -70,6 +70,13 @@ test("kernel env filtering keeps runtime configuration but drops credentials", ( }) }) +test("kernel subprocesses cannot fall back to host Git config or credential prompts", () => { + const env = OpenScience.kernelEnv({ PATH: "/usr/bin", HOME: "/home/researcher" }) + expect(env.GIT_CONFIG_NOSYSTEM).toBe("1") + expect(env.GIT_CONFIG_GLOBAL).toBe("/dev/null") + expect(env.GIT_TERMINAL_PROMPT).toBe("0") +}) + test("kernel credential mask covers Atlas and OpenScience credential stores", () => { const paths = OpenScience.kernelSensitivePaths() const names = paths.map((value) => path.basename(value)) @@ -77,6 +84,10 @@ test("kernel credential mask covers Atlas and OpenScience credential stores", () expect(names).toContain("auth.json") expect(names).toContain("credentials.json") expect(names).toContain("mcp-auth.json") + expect(names).toContain(".ssh") + expect(names).toContain(".aws") + expect(names).toContain(".netrc") + expect(names).toContain(".git-credentials") expect(paths).toContain( process.env.ATLAS_CLI_CONFIG_PATH || path.join(process.env.HOME!, ".config", "atlas-cli", "config.json"), ) diff --git a/backend/cli/test/openscience-logout.test.ts b/backend/cli/test/openscience-logout.test.ts index a6e3083e..914172b9 100644 --- a/backend/cli/test/openscience-logout.test.ts +++ b/backend/cli/test/openscience-logout.test.ts @@ -110,3 +110,77 @@ test("clearSession leaves a hand-configured atlas profile alone", async () => { const config = JSON.parse(await Bun.file(atlas).text()) expect(config.profiles.default.api_key).toBe("thk_mine.secret") }) + +test("logout in one server removes synced env and revokes inherited children in another", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-logout-revision-")) + const config = path.join(root, "config") + const managedDir = path.join(config, "openscience") + const worker = path.join(root, "worker.ts") + const clear = path.join(root, "clear.ts") + const ready = path.join(root, "ready") + const openscience = new URL("../src/openscience/index.ts", import.meta.url).href + const lifecycle = new URL("../src/credentials/lifecycle.ts", import.meta.url).href + await fs.mkdir(managedDir, { recursive: true }) + await Bun.write( + path.join(managedDir, "synced-env.json"), + JSON.stringify({ AWS_ACCESS_KEY_ID: "cross-managed-access", AWS_SECRET_ACCESS_KEY: "cross-managed-secret" }), + ) + await Bun.write( + path.join(root, "openscience-session.json"), + JSON.stringify({ api_key: "thk_test.secret", user_id: "u" }), + ) + await Bun.write( + clear, + [`import { OpenScience } from ${JSON.stringify(openscience)}`, `await OpenScience.clearSession()`].join("\n"), + ) + await Bun.write( + worker, + [ + `import fs from "node:fs/promises"`, + `import { spawn } from "node:child_process"`, + `import { OpenScience } from ${JSON.stringify(openscience)}`, + `import { CredentialLifecycle } from ${JSON.stringify(lifecycle)}`, + `await CredentialLifecycle.ensureFresh()`, + `const initial = await OpenScience.subprocessEnv(process.env)`, + `if (initial.AWS_SECRET_ACCESS_KEY !== "cross-managed-secret") throw new Error("worker did not load synced secret")`, + `const child = spawn(process.execPath, ["-e", "console.log(process.env.AWS_SECRET_ACCESS_KEY || 'absent'); setInterval(() => {}, 1000)"], { env: initial, stdio: ["ignore", "pipe", "pipe"] })`, + `const inherited = await new Promise((resolve, reject) => { child.stdout.once("data", (data) => resolve(String(data).trim())); child.once("error", reject) })`, + `if (inherited !== "cross-managed-secret") throw new Error("child did not inherit synced secret")`, + `let revoked = false`, + `CredentialLifecycle.onRevoke(async () => { revoked = true; child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)) })`, + `CredentialLifecycle.watch(25)`, + `await fs.writeFile(${JSON.stringify(ready)}, "ready")`, + `for (let i = 0; i < 400 && !revoked; i++) await Bun.sleep(10)`, + `await CredentialLifecycle.ensureFresh()`, + `if (!revoked || (child.exitCode === null && child.signalCode === null)) throw new Error("synced child was not revoked")`, + `if (process.env.AWS_SECRET_ACCESS_KEY !== undefined) throw new Error("logout left synced secret in process.env")`, + `const next = await OpenScience.subprocessEnv(process.env)`, + `if (next.AWS_SECRET_ACCESS_KEY !== undefined) throw new Error("new child env retained logged-out secret")`, + `CredentialLifecycle.stopWatching()`, + ].join("\n"), + ) + const env = { + ...process.env, + AWS_ACCESS_KEY_ID: "cross-managed-access", + AWS_SECRET_ACCESS_KEY: "cross-managed-secret", + OPENSCIENCE_DATA_DIR: root, + OPENSCIENCE_CONFIG_DIR: managedDir, + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + } + + try { + const live = Bun.spawn([process.execPath, worker], { env, stdout: "pipe", stderr: "pipe" }) + for (let i = 0; i < 400 && !(await Bun.file(ready).exists()); i++) await Bun.sleep(10) + expect(await Bun.file(ready).exists()).toBe(true) + const deleter = Bun.spawn([process.execPath, clear], { env, stdout: "pipe", stderr: "pipe" }) + const [clearExit, clearError] = await Promise.all([deleter.exited, new Response(deleter.stderr).text()]) + if (clearExit !== 0) throw new Error(clearError) + const [exit, error] = await Promise.all([live.exited, new Response(live.stderr).text()]) + if (exit !== 0) throw new Error(error) + expect(exit).toBe(0) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/openscience/dotenv.test.ts b/backend/cli/test/openscience/dotenv.test.ts index 82d0ca1c..4a394bb7 100644 --- a/backend/cli/test/openscience/dotenv.test.ts +++ b/backend/cli/test/openscience/dotenv.test.ts @@ -2,6 +2,7 @@ import { test, expect } from "bun:test" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" +import { pathToFileURL } from "node:url" import { parseDotenv, loadProjectDotenv } from "../../src/openscience/dotenv" test("parseDotenv handles export prefix, quotes, comments, blanks, and embedded =", () => { @@ -43,19 +44,39 @@ test("parseDotenv strips inline comments on unquoted values but keeps # inside q ]) }) -test("loadProjectDotenv skips execution-affecting vars and empty values", () => { +test("loadProjectDotenv skips host control-plane, routing, loader vars and empty values", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-dotenv-")) fs.writeFileSync( path.join(dir, ".env"), - "NODE_OPTIONS=--require /tmp/evil.js\nLD_PRELOAD=/tmp/evil.so\nEMPTY=\nANTHROPIC_API_KEY=sk-ant-ok\n", + [ + "OPENSCIENCE_CONFIG_CONTENT={malicious}", + "OPENSCIENCE_PERMISSION={malicious}", + "SYNSC_API_BASE=https://attacker.invalid", + "PATH=/tmp/attacker-bin", + "NODE_OPTIONS=--require /tmp/evil.js", + "LD_PRELOAD=/tmp/evil.so", + "HTTPS_PROXY=https://attacker.invalid", + "ANTHROPIC_BASE_URL=https://attacker.invalid", + "EMPTY=", + "RESEARCH_DATASET=local.csv", + "ANTHROPIC_API_KEY=sk-ant-ok", + "", + ].join("\n"), ) const env: NodeJS.ProcessEnv = {} const applied = loadProjectDotenv(dir, env) expect(env.NODE_OPTIONS).toBeUndefined() // dangerous — never from .env expect(env.LD_PRELOAD).toBeUndefined() + expect(env.OPENSCIENCE_CONFIG_CONTENT).toBeUndefined() + expect(env.OPENSCIENCE_PERMISSION).toBeUndefined() + expect(env.SYNSC_API_BASE).toBeUndefined() + expect(env.PATH).toBeUndefined() + expect(env.HTTPS_PROXY).toBeUndefined() + expect(env.ANTHROPIC_BASE_URL).toBeUndefined() expect(env.EMPTY).toBeUndefined() // empty skipped + expect(env.RESEARCH_DATASET).toBe("local.csv") expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-ok") - expect(applied).toEqual(["ANTHROPIC_API_KEY"]) + expect(applied).toEqual(["RESEARCH_DATASET", "ANTHROPIC_API_KEY"]) fs.rmSync(dir, { recursive: true, force: true }) }) @@ -86,3 +107,66 @@ test("loadProjectDotenv on a dir with no .env is a no-op", () => { expect(loadProjectDotenv(dir, env)).toEqual([]) fs.rmSync(dir, { recursive: true, force: true }) }) + +test("an untrusted repository dotenv cannot inject plugins, provider keys, or loader controls at boot", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-dotenv-boot-")) + const host = path.join(dir, "host") + const marker = path.join(dir, "plugin-ran") + const plugin = path.join(dir, "injected-plugin.ts") + const fixture = path.join(import.meta.dir, "..", "fixture", "dotenv-project-process.ts") + try { + fs.mkdirSync(host, { recursive: true }) + fs.writeFileSync( + plugin, + `await Bun.write(${JSON.stringify(marker)}, "executed")\nexport default async () => ({})\n`, + ) + fs.writeFileSync( + path.join(dir, ".env"), + [ + `OPENSCIENCE_CONFIG_CONTENT='${JSON.stringify({ plugin: [pathToFileURL(plugin).href] })}'`, + "OPENAI_API_KEY=attacker-owned-project-key", + `GIT_ASKPASS=${path.join(dir, "attacker-askpass")}`, + "", + ].join("\n"), + ) + const env = { ...process.env } + delete env.OPENSCIENCE_CONFIG_CONTENT + delete env.OPENAI_API_KEY + delete env.GIT_ASKPASS + env.OPENSCIENCE_TEST_HOME = host + env.OPENSCIENCE_CONFIG_DIR = path.join(host, "config") + env.OPENSCIENCE_DATA_DIR = path.join(host, "data") + const proc = Bun.spawn([process.execPath, fixture, marker], { + cwd: dir, + env, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + expect(code, stderr).toBe(0) + const result = stdout + .trim() + .split("\n") + .map((line) => { + try { + return JSON.parse(line) as { + marker: boolean + inline: string | null + provider: string | null + askpass: string | null + } + } catch { + return undefined + } + }) + .findLast(Boolean) + expect(result).toEqual({ marker: false, inline: null, provider: null, askpass: null }) + expect(fs.existsSync(marker)).toBe(false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/openscience/sync-precedence.test.ts b/backend/cli/test/openscience/sync-precedence.test.ts index 8dc8779b..6f8f978c 100644 --- a/backend/cli/test/openscience/sync-precedence.test.ts +++ b/backend/cli/test/openscience/sync-precedence.test.ts @@ -29,7 +29,9 @@ afterEach(async () => { delete process.env["GITHUB_TOKEN"] delete process.env["GH_TOKEN"] delete process.env["GOOGLE_APPLICATION_CREDENTIALS"] + delete process.env["GOOGLE_CLOUD_PROJECT"] if (gcp) await fs.rm(gcp, { force: true }) + await fs.rm(path.join(Global.Path.data, "openscience-session.json"), { force: true }) }) async function seedSession() { diff --git a/backend/cli/test/process/darwin-responsibility.test.ts b/backend/cli/test/process/darwin-responsibility.test.ts new file mode 100644 index 00000000..fb37f85a --- /dev/null +++ b/backend/cli/test/process/darwin-responsibility.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { DarwinResponsibility } from "../../src/process/darwin-responsibility" + +async function text(file: string, attempt = 0): Promise { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value?.trim()) return value.trim() + if (attempt >= 300) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return text(file, attempt + 1) +} + +async function gone(pid: number, attempt = 0): Promise { + try { + process.kill(pid, 0) + } catch { + return true + } + if (attempt >= 300) return false + await Bun.sleep(10) + return gone(pid, attempt + 1) +} + +test.skipIf(process.platform !== "darwin")( + "kernel responsibility tracks a setsid double-fork after it reparents to launchd", + async () => { + if (!Bun.which("python3")) return + expect(DarwinResponsibility.available()).toBe(true) + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-darwin-responsibility-")) + const marker = path.join(root, "daemon.pid") + const script = [ + "import os,time", + "os.fork() and os._exit(0)", + "os.setsid()", + "os.fork() and os._exit(0)", + `open(${JSON.stringify(marker)}, 'w').write(str(os.getpid()))`, + "time.sleep(120)", + ].join(";") + const supervisor = Bun.spawn( + [ + "python3", + "-c", + `import subprocess,time; subprocess.Popen(['python3','-c',${JSON.stringify(script)}]); time.sleep(120)`, + ], + { stdout: "ignore", stderr: "pipe" }, + ) + let daemon = 0 + try { + daemon = Number(await text(marker)) + expect(daemon).toBeGreaterThan(0) + const owner = DarwinResponsibility.responsible(supervisor.pid) + expect(owner).toBeGreaterThan(0) + expect(DarwinResponsibility.responsible(daemon)).toBe(owner) + expect(DarwinResponsibility.owns(owner!, daemon)).toBe(true) + expect(DarwinResponsibility.members(owner!)).toContain(daemon) + + // The daemon completed both forks and now has launchd as PPID, so this + // assertion exercises the exact case a PPID/PGID-only ledger loses. + const proc = Bun.spawn(["/bin/ps", "-o", "ppid=", "-p", String(daemon)], { stdout: "pipe" }) + const ppid = Number((await new Response(proc.stdout).text()).trim()) + expect(await proc.exited).toBe(0) + expect(ppid).toBe(1) + } finally { + if (daemon && !(await gone(daemon))) process.kill(daemon, "SIGKILL") + supervisor.kill("SIGKILL") + await supervisor.exited + await fs.rm(root, { recursive: true, force: true }) + } + }, + 15_000, +) diff --git a/backend/cli/test/process/windows-job.test.ts b/backend/cli/test/process/windows-job.test.ts new file mode 100644 index 00000000..0f78475c --- /dev/null +++ b/backend/cli/test/process/windows-job.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test" +import crypto from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { WindowsJob } from "../../src/process/windows-job" + +async function gone(pid: number, attempt = 0): Promise { + try { + process.kill(pid, 0) + } catch { + return true + } + if (attempt >= 300) return false + await Bun.sleep(10) + return gone(pid, attempt + 1) +} + +async function text(file: string, attempt = 0): Promise { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value?.trim()) return value.trim() + if (attempt >= 300) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return text(file, attempt + 1) +} + +const fixture = path.resolve(import.meta.dir, "../fixture/windows-job.ts") + +test("Windows Job Object limit buffer enables kill-on-close without breakaway flags", () => { + const info = WindowsJob.limitsForTests() + expect(info).toHaveLength(WindowsJob.EXTENDED_LIMIT_SIZE_X64) + expect(info.readUInt32LE(WindowsJob.LIMIT_FLAGS_OFFSET_X64)).toBe(WindowsJob.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + expect(info.readUInt32LE(WindowsJob.LIMIT_FLAGS_OFFSET_X64) & 0x00001800).toBe(0) +}) + +test("Windows Job Object names are local, random, and ledger-valid", () => { + const first = WindowsJob.name("same-runtime", "first") + const second = WindowsJob.name("same-runtime", "second") + expect(WindowsJob.valid(first)).toBe(true) + expect(WindowsJob.valid(second)).toBe(true) + expect(first).not.toBe(second) + expect(first).toMatch(/^Local\\OpenScience-[a-f0-9]{64}$/) +}) + +test("every durable Windows runtime launch uses the registration gate", async () => { + const files = [ + "src/pty/index.ts", + "src/tool/biology/notebook.ts", + "src/tool/notebook.ts", + "src/tool/rkernel.ts", + "src/tool/bash.ts", + "src/session/prompt.ts", + "src/file/publication.ts", + "src/format/index.ts", + "src/server/routes/repo.ts", + "src/lsp/server.ts", + "src/compute/jobs.ts", + "src/compute/modal/volume.ts", + "src/provider/token-command.ts", + "src/server/routes/settings/local.ts", + ] + for (const file of files) { + const source = await Bun.file(path.join(import.meta.dir, "../..", file)).text() + expect(source, file).toContain("WindowsJobLauncher") + expect(source, file).toContain(".release") + } + const authority = await Bun.file(path.join(import.meta.dir, "../../src/project/authority-process.ts")).text() + const credentials = await Bun.file(path.join(import.meta.dir, "../../src/credentials/process-ledger.ts")).text() + for (const source of [authority, credentials]) { + expect(source).toContain("WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity })") + expect(source).toContain("WindowsJob.terminate") + expect(source).toContain("windowsRelease") + expect(source).toContain('process.platform === "win32" ? "Windows Job Object" : "macOS responsibility"') + } + const mcp = await Bun.file(path.join(import.meta.dir, "../../src/mcp/index.ts")).text() + const launcher = await Bun.file(path.join(import.meta.dir, "../../src/mcp/group-launcher.ts")).text() + expect(mcp).toContain("windowsRelease: launcher.release") + expect(launcher).toContain("Timed out waiting for Windows Job Object ownership") +}) + +test.skipIf(process.platform !== "win32")( + "Windows Job Object assignment rejects a reused or mismatched process identity", + async () => { + const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }) + try { + expect(() => + WindowsJob.assign({ + id: `mismatch-${crypto.randomUUID()}`, + pid: child.pid, + expectedIdentity: "0".repeat(64), + }), + ).toThrow("changed identity before Windows Job Object assignment") + expect(WindowsJob.identity(child.pid)).toStartWith("win32:") + } finally { + child.kill("SIGKILL") + await child.exited + } + }, + 10_000, +) + +test.skipIf(process.platform !== "win32")( + "named Windows Job Object contains descendants and cross-process termination reaps the tree", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-windows-job-")) + const release = path.join(root, "release") + const descendant = path.join(root, "descendant") + const script = [ + 'const fs = require("node:fs")', + 'const cp = require("node:child_process")', + "const release = process.env.OPENSCIENCE_JOB_TEST_RELEASE", + "const descendant = process.env.OPENSCIENCE_JOB_TEST_DESCENDANT", + "const wait = () => {", + " if (!fs.existsSync(release)) return setTimeout(wait, 10)", + ' const child = cp.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" })', + " fs.writeFileSync(descendant, String(child.pid))", + " setInterval(() => {}, 1000)", + "}", + "wait()", + ].join("\n") + const child = Bun.spawn([process.execPath, "-e", script], { + env: { + ...process.env, + OPENSCIENCE_JOB_TEST_RELEASE: release, + OPENSCIENCE_JOB_TEST_DESCENDANT: descendant, + }, + stdout: "ignore", + stderr: "pipe", + windowsHide: true, + }) + let job: string | undefined + let descendantPID = 0 + try { + const identity = WindowsJob.identity(child.pid) + expect(identity).toStartWith("win32:") + job = WindowsJob.assign({ + id: `test-${crypto.randomUUID()}`, + pid: child.pid, + expectedIdentity: crypto.createHash("sha256").update(identity!).digest("hex"), + }) + expect(WindowsJob.heldForTests(job)).toBe(true) + expect(WindowsJob.contains(job, child.pid)).toBe(true) + await fs.writeFile(release, "ready") + descendantPID = Number(await text(descendant)) + expect(WindowsJob.contains(job, descendantPID)).toBe(true) + const revoker = Bun.spawn([process.execPath, fixture, "terminate", job], { + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const [code, stderr] = await Promise.all([revoker.exited, new Response(revoker.stderr).text()]) + expect(stderr).toBe("") + expect(code).toBe(0) + expect(await gone(child.pid)).toBe(true) + expect(await gone(descendantPID)).toBe(true) + expect(WindowsJob.terminate(job)).toBe(true) + expect(WindowsJob.heldForTests(job)).toBe(false) + } finally { + if (job && WindowsJob.heldForTests(job)) WindowsJob.terminate(job) + child.kill("SIGKILL") + if (descendantPID && !(await gone(descendantPID))) process.kill(descendantPID, "SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } + }, + 20_000, +) diff --git a/backend/cli/test/project/authority-process-ledger.test.ts b/backend/cli/test/project/authority-process-ledger.test.ts new file mode 100644 index 00000000..f212e03b --- /dev/null +++ b/backend/cli/test/project/authority-process-ledger.test.ts @@ -0,0 +1,279 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { AuthorityProcessLedger } from "../../src/project/authority-process" + +const runner = path.resolve(import.meta.dir, "../fixture/authority-runtime-process.ts") +const cwd = path.resolve(import.meta.dir, "../..") + +interface Setup { + projectID: string + sessionID: string + grantID: string + shell: string + descendantFile: string +} + +interface Entry { + pid: number + identity: string + project_id: string + session_id: string + authority_generation: string + descendant: { + pid: number + identity: string + pgid: number + ppid: number + } +} + +function environment(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_DATA_HOME: path.join(root, "xdg-data"), + XDG_CONFIG_HOME: path.join(root, "xdg-config"), + XDG_CACHE_HOME: path.join(root, "xdg-cache"), + XDG_STATE_HOME: path.join(root, "xdg-state"), + OPENSCIENCE_CONFIG_CONTENT: JSON.stringify({ sandbox: { enabled: false } }), + } +} + +async function run(root: string, ...args: string[]) { + const proc = Bun.spawn([process.execPath, runner, ...args], { + cwd, + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (code !== 0) throw new Error(`fixture ${args[0]} exited ${code}: ${stderr}`) +} + +async function waitJson(file: string, attempt = 0): Promise { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value) return value as T + if (attempt >= 300) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(20) + return waitJson(file, attempt + 1) +} + +async function gone(entry: Pick, attempt = 0): Promise { + if (!(await AuthorityProcessLedger.owns(entry.pid, entry.identity))) return true + if (attempt >= 200) return false + await Bun.sleep(20) + return gone(entry, attempt + 1) +} + +async function scenario(kind: "pty" | "biology", action: "trust" | "filesystem" | "session") { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `openscience-authority-${kind}-${action}-`)) + const workspace = path.join(root, "workspace") + const setupFile = path.join(root, "setup.json") + const ready = path.join(root, "ready.json") + await fs.mkdir(workspace, { recursive: true }) + let owner: ReturnType | undefined + let entry: Entry | undefined + try { + await run(root, "setup", workspace, setupFile) + const setup = await waitJson(setupFile) + owner = Bun.spawn( + [ + process.execPath, + runner, + `owner-${kind}`, + workspace, + ready, + setup.sessionID, + setup.grantID, + setup.shell, + setup.descendantFile, + ], + { cwd, env: environment(root), stdout: "pipe", stderr: "pipe" }, + ) + entry = await waitJson(ready).catch(async (error) => { + owner?.kill("SIGKILL") + await owner?.exited + const stderr = owner?.stderr instanceof ReadableStream ? await new Response(owner.stderr).text() : "" + throw new Error(`${error instanceof Error ? error.message : String(error)}\nowner stderr: ${stderr}`) + }) + expect(entry.project_id).toBe(setup.projectID) + expect(entry.session_id).toBe(setup.sessionID) + expect(entry.authority_generation).toHaveLength(64) + expect(await AuthorityProcessLedger.owns(entry.pid, entry.identity)).toBe(true) + expect(await AuthorityProcessLedger.owns(entry.descendant.pid, entry.descendant.identity)).toBe(true) + expect(entry.descendant.pgid).not.toBe(entry.pid) + expect(entry.descendant.ppid).toBe(1) + + owner.kill("SIGKILL") + await owner.exited + // Both fixtures ignore terminal hangup so the independently sandboxed + // leader and its escaped descendant genuinely outlive the killed server. + await Bun.sleep(100) + const survivedOwner = await AuthorityProcessLedger.owns(entry.pid, entry.identity) + // On macOS the responsibility supervisor observes the exact owner start + // identity and performs kernel-backed teardown immediately. Linux relies + // on durable cross-process revocation after owner loss. + expect(survivedOwner).toBe(process.platform !== "darwin") + + await run( + root, + `revoke-${action}`, + workspace, + path.join(root, "unused"), + setup.sessionID, + setup.grantID, + setup.shell, + ) + expect(await gone(entry)).toBe(true) + expect(await gone(entry.descendant)).toBe(true) + expect(await Bun.file(path.join(root, "data", "authority-processes.json")).json()).toEqual([]) + } finally { + if (owner) owner.kill("SIGKILL") + if (entry && (await AuthorityProcessLedger.owns(entry.pid, entry.identity))) { + await run(root, "reap", workspace, path.join(root, "unused"), "", "", "").catch(() => undefined) + } + if (entry?.descendant && (await AuthorityProcessLedger.owns(entry.descendant.pid, entry.descendant.identity))) { + process.kill(entry.descendant.pid, "SIGKILL") + } + await fs.rm(root, { recursive: true, force: true }) + } +} + +test("trust, filesystem, and session revocation reclaim PTY and biology children after owner SIGKILL", async () => { + if (process.platform === "win32") return + for (const kind of ["pty", "biology"] as const) { + for (const action of ["trust", "filesystem", "session"] as const) await scenario(kind, action) + } +}, 120_000) + +test("installation-scope revocation reaps killed-owner children from another project", async () => { + if (process.platform === "win32") return + for (const kind of ["pty", "biology"] as const) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `openscience-authority-installation-${kind}-`)) + const workspaceA = path.join(root, "workspace-a") + const workspaceB = path.join(root, "workspace-b") + const setupAFile = path.join(root, "setup-a.json") + const setupBFile = path.join(root, "setup-b.json") + const ready = path.join(root, "ready.json") + await Promise.all([fs.mkdir(workspaceA, { recursive: true }), fs.mkdir(workspaceB, { recursive: true })]) + let owner: ReturnType | undefined + let entry: Entry | undefined + try { + await run(root, "setup-installation", workspaceA, setupAFile) + await run(root, "setup", workspaceB, setupBFile) + const setupA = await waitJson(setupAFile) + const setupB = await waitJson(setupBFile) + expect(setupB.projectID).not.toBe(setupA.projectID) + owner = Bun.spawn( + [ + process.execPath, + runner, + `owner-${kind}`, + workspaceB, + ready, + setupB.sessionID, + setupB.grantID, + setupB.shell, + setupB.descendantFile, + ], + { cwd, env: environment(root), stdout: "pipe", stderr: "pipe" }, + ) + entry = await waitJson(ready) + owner.kill("SIGKILL") + await owner.exited + + await run( + root, + "revoke-filesystem", + workspaceA, + path.join(root, "unused"), + setupA.sessionID, + setupA.grantID, + setupA.shell, + ) + expect(await gone(entry)).toBe(true) + expect(await gone(entry.descendant)).toBe(true) + expect(await Bun.file(path.join(root, "data", "authority-processes.json")).json()).toEqual([]) + } finally { + owner?.kill("SIGKILL") + if (entry && (await AuthorityProcessLedger.owns(entry.pid, entry.identity))) { + await run(root, "reap", workspaceB, path.join(root, "unused"), "", "", "").catch(() => undefined) + } + if (entry?.descendant && (await AuthorityProcessLedger.owns(entry.descendant.pid, entry.descendant.identity))) { + process.kill(entry.descendant.pid, "SIGKILL") + } + await fs.rm(root, { recursive: true, force: true }) + } + } +}, 60_000) + +test("ledger refuses mismatched identities and POSIX children without private process groups", async () => { + if (process.platform === "win32" || process.platform === "darwin") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-authority-safety-")) + const workspace = path.join(root, "workspace") + await fs.mkdir(workspace, { recursive: true }) + try { + const mismatch = path.join(root, "mismatch.json") + await run(root, "mismatched-identity", workspace, mismatch) + expect(await waitJson<{ killed: number; survived: boolean }>(mismatch)).toEqual({ killed: 0, survived: true }) + + const group = path.join(root, "group.json") + await run(root, "non-group", workspace, group) + expect(await waitJson<{ error: string }>(group)).toMatchObject({ + error: expect.stringContaining("not its own process-group leader"), + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("normal leader exit reaps and verifies a surviving same-group child before completing", async () => { + if (process.platform === "win32" || process.platform === "darwin") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-authority-leader-exit-")) + const workspace = path.join(root, "workspace") + const result = path.join(root, "result.json") + await fs.mkdir(workspace, { recursive: true }) + try { + await run(root, "leader-exit-grandchild", workspace, result) + const outcome = await waitJson<{ + completed: boolean + child: { pid: number; identity: string } + survived: boolean + }>(result) + expect(outcome.completed).toBe(true) + expect(outcome.survived).toBe(false) + expect(await AuthorityProcessLedger.owns(outcome.child.pid, outcome.child.identity)).toBe(false) + expect(await Bun.file(path.join(root, "data", "authority-processes.json")).json()).toEqual([]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test.skipIf(process.platform !== "darwin")("Darwin authority registration rejects an unwrapped runtime", async () => { + const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdout: "ignore", + stderr: "ignore", + }) + try { + await expect( + AuthorityProcessLedger.register({ + id: `authority-unwrapped-${crypto.randomUUID()}`, + kind: "biology", + pid: child.pid, + projectID: "project-unwrapped", + sessionID: "session-unwrapped", + authorityGeneration: "unwrapped-generation", + }), + ).rejects.toThrow("macOS responsibility registration gate") + } finally { + process.kill(-child.pid, "SIGKILL") + await child.exited + } +}) diff --git a/backend/cli/test/project/execution-cache-revocation.test.ts b/backend/cli/test/project/execution-cache-revocation.test.ts new file mode 100644 index 00000000..b1e901d3 --- /dev/null +++ b/backend/cli/test/project/execution-cache-revocation.test.ts @@ -0,0 +1,240 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Agent } from "../../src/agent/agent" +import { Command } from "../../src/command" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Skill } from "../../src/skill" +import { ToolRegistry } from "../../src/tool/registry" +import { tmpdir } from "../fixture/fixture" + +const context = (sessionID: string) => ({ + sessionID, + messageID: "msg_revocation_cache", + callID: "call_revocation_cache", + agent: "research" as const, + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +}) + +async function waitForFile(file: string, attempts = 200) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (await Bun.file(file).exists()) return + await Bun.sleep(25) + } + throw new Error(`Timed out waiting for ${file}`) +} + +test("trust revocation acknowledges eviction of loaded project commands and tools", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const commandMarker = path.join(directory, "command-ran") + const toolMarker = path.join(directory, "tool-ran") + const importMarker = path.join(directory, "tool-imported") + const commandRoot = path.join(directory, ".openscience", "command") + const toolRoot = path.join(directory, ".openscience", "tool") + const skillRoot = path.join(directory, ".openscience", "skill", "revocable-skill") + await fs.mkdir(commandRoot, { recursive: true }) + await fs.mkdir(toolRoot, { recursive: true }) + await fs.mkdir(skillRoot, { recursive: true }) + await Bun.write( + path.join(directory, "openscience.json"), + JSON.stringify({ + agent: { + "revocable-agent": { mode: "subagent", description: "Revocable agent" }, + }, + }), + ) + await Bun.write( + path.join(skillRoot, "SKILL.md"), + ["---", "name: revocable-skill", "description: Revocable skill", "---", "Project instructions"].join("\n"), + ) + await Bun.write( + path.join(commandRoot, "revocable.md"), + ["---", "description: Revocable command", "---", `!\`printf command > ${JSON.stringify(commandMarker)}\``].join( + "\n", + ), + ) + await Bun.write( + path.join(toolRoot, "revocable.ts"), + [ + `await Bun.write(${JSON.stringify(importMarker)}, "imported")`, + "export default {", + " description: 'Revocable tool',", + " args: {},", + " execute: async () => {", + ` const file = Bun.file(${JSON.stringify(toolMarker)})`, + ` await Bun.write(${JSON.stringify(toolMarker)}, await file.text().catch(() => "") + "x")`, + " return 'ran'", + " },", + "}", + "", + ].join("\n"), + ) + return { commandMarker, toolMarker, importMarker } + }, + }) + + await Instance.provide({ + directory: tmp.path, + init: InstanceBootstrap, + fn: async () => { + try { + const initial = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: initial.root }) + const session = await Session.create({ + permission: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + + expect((await Command.get("revocable"))?.description).toBe("Revocable command") + expect((await Agent.get("revocable-agent"))?.description).toBe("Revocable agent") + expect((await Skill.get("revocable-skill"))?.origin).toBe("project") + const loaded = await ToolRegistry.tools({ providerID: "test", modelID: "test" }) + const held = loaded.find((tool) => tool.id === "revocable") + expect(held).toBeDefined() + await held!.execute({}, context(session.id)) + expect(await Bun.file(tmp.extra.toolMarker).text()).toBe("x") + expect(await Bun.file(tmp.extra.importMarker).text()).toBe("imported") + + // ProjectTrust.update awaits the local Bus handler. The first reads + // after this response must already reflect revoked authority. + const revoked = await ProjectTrust.update(Instance.project, { trusted: false }) + expect(revoked.state).toBe("revoked") + expect(await Command.get("revocable")).toBeUndefined() + expect(await Agent.get("revocable-agent")).toBeUndefined() + expect(await Skill.get("revocable-skill")).toBeUndefined() + expect(await ToolRegistry.ids()).not.toContain("revocable") + + await expect(held!.execute({}, context(session.id))).rejects.toBeInstanceOf(ProjectTrust.DeniedError) + expect(await Bun.file(tmp.extra.toolMarker).text()).toBe("x") + await expect( + SessionPrompt.command({ + sessionID: session.id, + command: "revocable", + arguments: "", + model: "test/model", + }), + ).rejects.toBeDefined() + expect(await Bun.file(tmp.extra.commandMarker).exists()).toBe(false) + } finally { + await Instance.dispose() + } + }, + }) +}, 30_000) + +test("the durable authority watcher evicts project execution caches in another process", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const commandRoot = path.join(directory, ".openscience", "command") + const toolRoot = path.join(directory, ".openscience", "tool") + const ready = path.join(directory, "watcher-ready") + const result = path.join(directory, "watcher-result") + await fs.mkdir(commandRoot, { recursive: true }) + await fs.mkdir(toolRoot, { recursive: true }) + await Bun.write( + path.join(commandRoot, "remote-revocable.md"), + ["---", "description: Remote revocable command", "---", "Never execute"].join("\n"), + ) + await Bun.write( + path.join(toolRoot, "remote-revocable.ts"), + [ + "export default {", + " description: 'Remote revocable tool',", + " args: {},", + " execute: async () => 'ran',", + "}", + "", + ].join("\n"), + ) + return { ready, result } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) + + const modules = { + bootstrap: new URL("../../src/project/bootstrap.ts", import.meta.url).href, + command: new URL("../../src/command/index.ts", import.meta.url).href, + instance: new URL("../../src/project/instance.ts", import.meta.url).href, + session: new URL("../../src/session/index.ts", import.meta.url).href, + tool: new URL("../../src/tool/registry.ts", import.meta.url).href, + trust: new URL("../../src/project/trust.ts", import.meta.url).href, + } + const childScript = [ + `import { InstanceBootstrap } from ${JSON.stringify(modules.bootstrap)}`, + `import { Command } from ${JSON.stringify(modules.command)}`, + `import { Instance } from ${JSON.stringify(modules.instance)}`, + `import { Session } from ${JSON.stringify(modules.session)}`, + `import { ToolRegistry } from ${JSON.stringify(modules.tool)}`, + `import { ProjectTrust } from ${JSON.stringify(modules.trust)}`, + "const [directory, ready, result] = process.argv.slice(1)", + "await Instance.provide({ directory, init: InstanceBootstrap, fn: async () => {", + " try {", + " const session = await Session.create({})", + " const commandLoaded = (await Command.get('remote-revocable'))?.description === 'Remote revocable command'", + " const tools = await ToolRegistry.tools({ providerID: 'test', modelID: 'test' })", + " const held = tools.find((tool) => tool.id === 'remote-revocable')", + " await Bun.write(ready, JSON.stringify({ commandLoaded, toolLoaded: !!held }))", + " let evicted = false", + " for (let attempt = 0; attempt < 200; attempt++) {", + " const commandMissing = (await Command.get('remote-revocable')) === undefined", + " const toolMissing = !(await ToolRegistry.ids()).includes('remote-revocable')", + " if (commandMissing && toolMissing) { evicted = true; break }", + " await Bun.sleep(25)", + " }", + " let heldDenied = false", + " try {", + " await held.execute({}, { sessionID: session.id, messageID: 'msg_remote', callID: 'call_remote', agent: 'research', abort: AbortSignal.any([]), messages: [], metadata() {}, async ask() {} })", + " } catch (error) { heldDenied = ProjectTrust.DeniedError.isInstance(error) }", + " await Bun.write(result, JSON.stringify({ commandLoaded, toolLoaded: !!held, evicted, heldDenied }))", + " } catch (error) {", + " await Bun.write(result, JSON.stringify({ error: error instanceof Error ? error.stack : String(error) }))", + " } finally { await Instance.dispose() }", + "} })", + ].join("\n") + const child = Bun.spawn([process.execPath, "-e", childScript, tmp.path, tmp.extra.ready, tmp.extra.result], { + cwd: tmp.path, + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }) + + try { + await waitForFile(tmp.extra.ready) + const ready = await Bun.file(tmp.extra.ready).json() + expect(ready, JSON.stringify(ready)).toMatchObject({ commandLoaded: true, toolLoaded: true }) + await Instance.provide({ + directory: tmp.path, + fn: () => ProjectTrust.update(Instance.project, { trusted: false }), + }) + await waitForFile(tmp.extra.result, 400) + const code = await Promise.race([child.exited, Bun.sleep(10_000).then(() => -1)]) + if (code === -1) throw new Error("Durable watcher fixture did not exit") + const stderr = await new Response(child.stderr).text() + expect(code, stderr).toBe(0) + expect(await Bun.file(tmp.extra.result).json()).toEqual({ + commandLoaded: true, + toolLoaded: true, + evicted: true, + heldDenied: true, + }) + } finally { + if (child.exitCode === null) child.kill("SIGKILL") + await child.exited.catch(() => {}) + } +}, 30_000) diff --git a/backend/cli/test/project/execution-trust.test.ts b/backend/cli/test/project/execution-trust.test.ts index 92a762a0..283e735d 100644 --- a/backend/cli/test/project/execution-trust.test.ts +++ b/backend/cli/test/project/execution-trust.test.ts @@ -122,8 +122,9 @@ test("built-in project LSP denies, executes when trusted, and stops its cached c const marker = path.join(dir, "lsp-started") const bin = path.join(dir, "node_modules", ".bin", "biome") const file = path.join(dir, "test.jsonc") - const server = path.join(import.meta.dir, "../fixture/lsp/fake-lsp-server.js") + const server = path.join(dir, "fake-lsp-server.js") await fs.mkdir(path.dirname(bin), { recursive: true }) + await fs.copyFile(path.join(import.meta.dir, "../fixture/lsp/fake-lsp-server.js"), server) await Bun.write(path.join(dir, "biome.json"), "{}") await Bun.write( bin, diff --git a/backend/cli/test/project/policy-trust.test.ts b/backend/cli/test/project/policy-trust.test.ts new file mode 100644 index 00000000..d109debf --- /dev/null +++ b/backend/cli/test/project/policy-trust.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import { PermissionNext } from "../../src/permission/next" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Session } from "../../src/session" +import { SessionFilesystem } from "../../src/session/filesystem" +import { tmpdir } from "../fixture/fixture" + +test("untrusted project agent and permission policy cannot auto-grant external paths", async () => { + await using external = await tmpdir() + await using tmp = await tmpdir({ + init: async (directory) => { + await Bun.write( + path.join(directory, "openscience.json"), + JSON.stringify({ + default_agent: "repo-agent", + permission: { external_directory: "allow", read: "allow" }, + tools: { bash: true }, + agent: { + "repo-agent": { + mode: "primary", + prompt: "repository-controlled", + permission: { external_directory: "allow" }, + }, + research: { permission: { external_directory: "allow" } }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect((await ProjectTrust.status(Instance.project)).canExecuteProjectCode).toBe(false) + expect((await Config.get()).permission?.external_directory).toBe("allow") // inspectable + const executable = await Config.getExecution() + expect(executable.permission?.external_directory).toBeUndefined() + expect(executable.tools?.bash).toBeUndefined() + expect(executable.default_agent).not.toBe("repo-agent") + expect(await Agent.get("repo-agent")).toBeUndefined() + + const research = await Agent.get("research") + expect(research).toBeTruthy() + expect(PermissionNext.evaluate("external_directory", external.path, research!.permission).action).toBe("ask") + + // Defence in depth: even a stale/caller-supplied configured allow rule is + // downgraded to a real approval prompt while the project is untrusted. + const session = await Session.create({}) + const request = PermissionNext.ask({ + id: "permission_untrusted_external", + sessionID: session.id, + permission: "external_directory", + patterns: [external.path], + always: [external.path], + metadata: { filesystem: { path: external.path, access: "read" } }, + ruleset: [{ permission: "external_directory", pattern: "*", action: "allow" }], + }) + await Bun.sleep(20) + await PermissionNext.reply({ requestID: "permission_untrusted_external", reply: "reject" }) + await expect(request).rejects.toBeInstanceOf(PermissionNext.RejectedError) + expect( + await SessionFilesystem.allows({ + sessionID: session.id, + path: external.path, + access: "read", + }), + ).toBe(false) + }, + }) +}) diff --git a/backend/cli/test/project/trust.test.ts b/backend/cli/test/project/trust.test.ts index 8856d6bf..a12577e7 100644 --- a/backend/cli/test/project/trust.test.ts +++ b/backend/cli/test/project/trust.test.ts @@ -11,6 +11,8 @@ import { Server } from "../../src/server/server" import { Skill } from "../../src/skill" import { Worktree } from "../../src/worktree" import { Global } from "../../src/global" +import { Storage } from "../../src/storage/storage" +import { Bus } from "../../src/bus" import { tmpdir } from "../fixture/fixture" async function skill(file: string, name: string) { @@ -27,7 +29,75 @@ description: ${name} trust test skill. ) } -test("project code is enabled by default", async () => { +test("repeated trust decisions preserve authority until the state actually changes", async () => { + await using tmp = await tmpdir() + await using stale = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const initial = await ProjectTrust.status(Instance.project) + const trusted = await ProjectTrust.update(Instance.project, { trusted: true, root: initial.root }) + const first = await Storage.read<{ revision: number }>(["authority", "revision"]) + + const repeated = await ProjectTrust.update(Instance.project, { trusted: true, root: initial.root }) + const second = await Storage.read<{ revision: number }>(["authority", "revision"]) + expect(repeated.revision).toBe(trusted.revision) + expect(second.revision).toBe(first.revision) + + await expect(ProjectTrust.update(Instance.project, { trusted: true, root: stale.path })).rejects.toBeInstanceOf( + ProjectTrust.RootMismatchError, + ) + expect((await ProjectTrust.status(Instance.project)).revision).toBe(trusted.revision) + expect((await Storage.read<{ revision: number }>(["authority", "revision"])).revision).toBe(first.revision) + + const revoked = await ProjectTrust.update(Instance.project, { trusted: false }) + const revokedSignal = await Storage.read<{ revision: number }>(["authority", "revision"]) + expect(revoked.revision).toBe(trusted.revision + 1) + expect(revokedSignal.revision).toBe(first.revision + 1) + + const repeatedRevoke = await ProjectTrust.update(Instance.project, { trusted: false }) + expect(repeatedRevoke.revision).toBe(revoked.revision) + expect((await Storage.read<{ revision: number }>(["authority", "revision"])).revision).toBe( + revokedSignal.revision, + ) + }, + }) +}) + +test("an identical trust decision retries cleanup left pending by a failed reaper", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const initial = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: initial.root }) + + let attempts = 0 + const unsubscribe = Bus.subscribe(ProjectTrust.Event.Changed, (event) => { + if (event.properties.status.state !== "revoked") return + attempts += 1 + if (attempts === 1) throw new Error("simulated reaper failure") + }) + + try { + await expect(ProjectTrust.update(Instance.project, { trusted: false })).rejects.toThrow( + "simulated reaper failure", + ) + expect((await ProjectTrust.status(Instance.project)).state).toBe("revoked") + expect((await Storage.read<{ pending: boolean }>(["authority", "revision"])).pending).toBe(true) + + const retried = await ProjectTrust.update(Instance.project, { trusted: false }) + expect(retried.state).toBe("revoked") + expect(attempts).toBe(2) + expect((await Storage.read<{ pending: boolean }>(["authority", "revision"])).pending).toBe(false) + } finally { + unsubscribe() + } + }, + }) +}) + +test("project code is inspectable but in-process plugins stay blocked by the execution sandbox", async () => { await using tmp = await tmpdir({ init: async (dir) => { const local = path.join(dir, ".openscience") @@ -87,23 +157,38 @@ export default async function Probe() { const skills = await Skill.all() const mcps = await MCP.status() - expect(status.state).toBe("trusted") + expect(status.state).toBe("untrusted") expect(status.source).toBe("default") - expect(status.canExecuteProjectCode).toBe(true) - expect(status.remediation).toBeUndefined() + expect(status.canExecuteProjectCode).toBe(false) + expect(status.remediation?.body).toEqual({ trusted: true, root: status.root }) expect(visible.mcp?.probe).toBeDefined() - expect(executable.mcp?.probe).toBeDefined() - expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeDefined() - expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeDefined() - expect(skills.some((item) => item.name === "project-probe")).toBe(true) - expect(mcps.probe).toBeDefined() + expect(executable.mcp?.probe).toBeUndefined() + expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeUndefined() + expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeUndefined() + expect(skills.some((item) => item.name === "project-probe")).toBe(false) + expect(mcps.probe).toBeUndefined() }, }) - expect(await Bun.file(tmp.extra).exists()).toBe(true) + expect(await Bun.file(tmp.extra).exists()).toBe(false) + + await Instance.disposeAll() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) + await Instance.disposeAll() + await Instance.provide({ directory: tmp.path, init: Plugin.init, fn: () => undefined }) + // Trust permits project subprocesses, but a plugin is imported into the host + // process itself. The OS execution sandbox cannot isolate that import, so the + // bounded policy refuses it while sandboxing is enabled. + expect(await Bun.file(tmp.extra).exists()).toBe(false) }) -test("trust is canonical, project-isolated, and revocation stops project hooks", async () => { +test("trust is canonical and project-isolated while sandboxed project hooks remain inert", async () => { await using first = await tmpdir({ init: async (dir) => { const marker = path.join(dir, "hook-ran") @@ -159,8 +244,9 @@ test("trust is canonical, project-isolated, and revocation stops project hooks", }) expect(alias.state).toBe("trusted") expect(alias.root).toBe(trusted.root) - expect(isolated.state).toBe("trusted") + expect(isolated.state).toBe("untrusted") expect(isolated.source).toBe("default") + expect(isolated.canExecuteProjectCode).toBe(false) expect(isolated.projectID).not.toBe(trusted.projectID) await Instance.disposeAll() @@ -169,9 +255,8 @@ test("trust is canonical, project-isolated, and revocation stops project hooks", init: Plugin.init, fn: () => undefined, }) - expect(await Bun.file(first.extra).text()).toBe("ran") + expect(await Bun.file(first.extra).exists()).toBe(false) - await fs.rm(first.extra) const revoked = await Instance.provide({ directory: first.path, fn: async () => { @@ -193,7 +278,7 @@ test("trust is canonical, project-isolated, and revocation stops project hooks", expect(await Bun.file(first.extra).exists()).toBe(false) }) -test("user-global and project-local plugins and skills are available by default", async () => { +test("user-global code stays available while project-local skills require trust", async () => { const file = path.join(Global.Path.home, ".claude", "skills", "global-probe", "SKILL.md") const global = path.dirname(file) const plugin = path.join(Global.Path.config, "plugin", "global-probe.ts") @@ -225,7 +310,7 @@ test("user-global and project-local plugins and skills are available by default" fn: async () => { const skills = await Skill.all() expect(skills.some((item) => item.name === "global-probe")).toBe(true) - expect(skills.some((item) => item.name === "local-probe")).toBe(true) + expect(skills.some((item) => item.name === "local-probe")).toBe(false) }, }) expect(await Bun.file(marker).text()).toBe("ran") @@ -291,7 +376,7 @@ test("revoked startup scripts fail closed before spawning a shell", async () => expect(await Bun.file(marker).text()).toBe("startup") }) -test("default trust is inspectable and revocable through the project permission surface", async () => { +test("default denial is inspectable, trustable, and revocable through the project permission surface", async () => { await using tmp = await tmpdir() const project = await Project.fromDirectory(tmp.path) const fetch = Server.internalFetch() @@ -306,8 +391,20 @@ test("default trust is inspectable and revocable through the project permission expect(status).toMatchObject({ projectID: project.project.id, root: project.project.worktree, - state: "trusted", + state: "untrusted", source: "default", + canExecuteProjectCode: false, + remediation: { code: "trust_project_required" }, + }) + + const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, { + method: "PUT", + headers, + body: JSON.stringify(status.remediation?.body), + }) + expect(trusted.status).toBe(200) + expect(await trusted.json()).toMatchObject({ + state: "trusted", canExecuteProjectCode: true, }) @@ -320,20 +417,6 @@ test("default trust is inspectable and revocable through the project permission expect(await revoked.json()).toMatchObject({ state: "revoked", canExecuteProjectCode: false, - remediation: { - code: "trust_project_required", - }, - }) - - const disabled = await ProjectTrust.status(project.project) - const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, { - method: "PUT", - headers, - body: JSON.stringify(disabled.remediation?.body), - }) - expect(trusted.status).toBe(200) - expect(await trusted.json()).toMatchObject({ - state: "trusted", - canExecuteProjectCode: true, + remediation: { code: "trust_project_required" }, }) }) diff --git a/backend/cli/test/provider/managed-routing.test.ts b/backend/cli/test/provider/managed-routing.test.ts index 578de4cf..355e90d6 100644 --- a/backend/cli/test/provider/managed-routing.test.ts +++ b/backend/cli/test/provider/managed-routing.test.ts @@ -617,15 +617,21 @@ describe("billing.llm gates OpenRouter's own-key vs managed-proxy route (1a/1b/1 test('1c (narrowed): an autoloaded custom-loader provider that also appears in config.provider (for its whitelist) still reports source "config", not "custom"', async () => { // google-vertex autoloads off GOOGLE_CLOUD_PROJECT alone (no auth.json - // entry, and its models.dev `env` array — GOOGLE_VERTEX_PROJECT etc. — - // never matches, so the "load env" stage never registers it either). + // entry). This fixture clears its catalog `env` list below so unrelated + // ambient Vertex credentials cannot let the "load env" stage claim it. // CUSTOM_LOADERS is the first and only stage to register it, with // source "custom" — exactly the loader-assigned (not credential-derived) // case 1c must keep overwriting to "config" when a config.provider entry // exists, per the narrowed protected set (env/api/managed only). await using tmp = await tmpdir({ config: { - provider: { "google-vertex": { whitelist: ["gemini-3.5-flash"] } }, + provider: { + // Keep this provenance fixture hermetic when another suite case has + // installed a real Vertex credential in the process environment. + // GOOGLE_CLOUD_PROJECT still drives the custom-loader autoload below; + // an empty catalog env list ensures only that loader claims it first. + "google-vertex": { env: [], whitelist: ["gemini-3.5-flash"] }, + }, }, }) await Instance.provide({ diff --git a/backend/cli/test/provider/token-command-process.test.ts b/backend/cli/test/provider/token-command-process.test.ts new file mode 100644 index 00000000..e8d8498d --- /dev/null +++ b/backend/cli/test/provider/token-command-process.test.ts @@ -0,0 +1,190 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" +import { CredentialLifecycle } from "../../src/credentials/lifecycle" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { ProviderTokenCommand } from "../../src/provider/token-command" +import { tmpdir, trustProject } from "../fixture/fixture" + +const posixTest = process.platform === "win32" ? test.skip : test +const darwinTest = process.platform === "darwin" ? test : test.skip + +function quote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'` +} + +async function waitText(file: string): Promise { + for (let attempt = 0; attempt < 500; attempt++) { + const value = await Bun.file(file) + .text() + .catch(() => undefined) + if (value?.trim()) return value.trim() + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +test("token helper environment excludes ambient provider and injection secrets", () => { + const env = ProviderTokenCommand.environment({ + PATH: "/usr/bin", + HOME: "/tmp/home", + LANG: "en_US.UTF-8", + AWS_PROFILE: "research", + OPENAI_API_KEY: "provider-secret", + OPENSCIENCE_TOKEN: "control-secret", + LD_PRELOAD: "/tmp/inject.so", + DYLD_INSERT_LIBRARIES: "/tmp/inject.dylib", + NODE_OPTIONS: "--require=/tmp/inject.js", + PYTHONPATH: "/tmp/inject-python", + }) + expect(env).toMatchObject({ PATH: "/usr/bin", HOME: "/tmp/home", LANG: "en_US.UTF-8", AWS_PROFILE: "research" }) + expect(env).not.toHaveProperty("OPENAI_API_KEY") + expect(env).not.toHaveProperty("OPENSCIENCE_TOKEN") + expect(env).not.toHaveProperty("LD_PRELOAD") + expect(env).not.toHaveProperty("DYLD_INSERT_LIBRARIES") + expect(env).not.toHaveProperty("NODE_OPTIONS") + expect(env).not.toHaveProperty("PYTHONPATH") +}) + +posixTest("token helper enforces stdout bounds and reaps the owned process", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + const script = path.join(directory, "large-token.js") + await Bun.write(script, `process.stdout.write("x".repeat(${ProviderTokenCommand.MAX_STDOUT_BYTES + 1}))`) + return script + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + try { + await expect( + ProviderTokenCommand.run({ + command: `${quote(process.execPath)} ${quote(tmp.extra)}`, + projectDeclared: false, + }), + ).rejects.toThrow(`stdout exceeded ${ProviderTokenCommand.MAX_STDOUT_BYTES} bytes`) + } finally { + await ProviderTokenCommand.revoke(Instance.project.id) + await Instance.dispose() + } + }, + }) +}) + +posixTest("token helper timeout kills its durable process tree", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const started = Date.now() + try { + await expect( + ProviderTokenCommand.run({ + command: "sleep 600", + projectDeclared: false, + timeoutMs: 100, + }), + ).rejects.toThrow("tokenCommand timed out after 100ms") + expect(Date.now() - started).toBeLessThan(5_000) + } finally { + await ProviderTokenCommand.revoke(Instance.project.id) + await Instance.dispose() + } + }, + }) +}) + +posixTest("credential revision revokes an in-flight token helper", async () => { + await using tmp = await tmpdir({ init: async (directory) => path.join(directory, "helper.pid") }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let pid = 0 + let identity: string | undefined + const running = ProviderTokenCommand.run({ + command: `printf %s $$ > ${quote(tmp.extra)}; sleep 600`, + projectDeclared: false, + timeoutMs: 20_000, + }).then( + (token) => ({ ok: true as const, token }), + (error) => ({ ok: false as const, error }), + ) + try { + pid = Number(await waitText(tmp.extra)) + identity = await CredentialProcessLedger.identity(pid) + expect(identity).toMatch(/^[a-f0-9]{64}$/) + await CredentialLifecycle.mutate("token-command-process-test", async () => undefined) + expect((await running).ok).toBe(false) + expect(await CredentialProcessLedger.owns(pid, identity)).toBe(false) + } finally { + await ProviderTokenCommand.revoke(Instance.project.id).catch(() => undefined) + if (pid && (await CredentialProcessLedger.owns(pid, identity))) process.kill(pid, "SIGKILL") + await Instance.dispose() + } + }, + }) +}) + +darwinTest( + "project trust revocation reaps a fully reparented token-helper daemon", + async () => { + const python = Bun.which("python3") + if (!python) return + await using tmp = await tmpdir({ + init: async (directory) => { + const marker = path.join(directory, "daemon.pid") + const script = path.join(directory, "daemon.py") + await Bun.write( + script, + [ + "import os, signal, time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "if os.fork(): os._exit(0)", + "os.setsid()", + "if os.fork(): os._exit(0)", + `with open(${JSON.stringify(marker)}, "w") as handle: handle.write(str(os.getpid()))`, + "time.sleep(600)", + ].join("\n"), + ) + return { marker, script } + }, + }) + await Instance.provide({ + directory: tmp.path, + init: InstanceBootstrap, + fn: async () => { + await trustProject() + const projectID = Instance.project.id + let daemon = 0 + let identity: string | undefined + const running = ProviderTokenCommand.run({ + command: `${quote(python)} ${quote(tmp.extra.script)}; sleep 600`, + projectDeclared: true, + timeoutMs: 20_000, + }).then( + (token) => ({ ok: true as const, token }), + (error) => ({ ok: false as const, error }), + ) + try { + daemon = Number(await waitText(tmp.extra.marker)) + identity = await CredentialProcessLedger.identity(daemon) + expect(identity).toMatch(/^[a-f0-9]{64}$/) + + await ProjectTrust.update(Instance.project, { trusted: false }) + const result = await running + expect(result.ok).toBe(false) + expect(await CredentialProcessLedger.owns(daemon, identity)).toBe(false) + } finally { + await ProviderTokenCommand.revoke(projectID).catch(() => undefined) + if (daemon && (await CredentialProcessLedger.owns(daemon, identity))) process.kill(daemon, "SIGKILL") + await Instance.dispose() + } + }, + }) + }, + 30_000, +) diff --git a/backend/cli/test/provider/token-command.test.ts b/backend/cli/test/provider/token-command.test.ts index e5b4621c..9529932a 100644 --- a/backend/cli/test/provider/token-command.test.ts +++ b/backend/cli/test/provider/token-command.test.ts @@ -273,3 +273,117 @@ test("tokenCommand overrides a static apiKey (command wins)", async () => { expect(srv.seen[0]).toBe("Bearer fresh-token") expect(srv.seen[0]).not.toContain("static-key-should-lose") }) + +test.skipIf(process.platform === "win32")("tokenCommand does not inherit ambient provider secrets", async () => { + const srv = echoServer() + process.env.OPENSCIENCE_TOKEN_HELPER_TEST_SECRET = "must-not-leak" + try { + await using tmp = await tmpdir({ + init: (dir) => + provider(dir, { + baseURL: srv.url, + tokenCommand: + 'if [ -z "$OPENSCIENCE_TOKEN_HELPER_TEST_SECRET" ]; then printf scrubbed-token; else printf leaked-token; fi', + }), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trust() + const model = await Provider.getModel("token-cmd", "m") + const language = await Provider.getLanguage(model) + await generateText({ model: language, prompt: "hi" }).catch(() => {}) + }, + }) + } finally { + delete process.env.OPENSCIENCE_TOKEN_HELPER_TEST_SECRET + srv.stop() + } + expect(srv.seen[0]).toBe("Bearer scrubbed-token") +}) + +test.skipIf(process.platform === "win32")("tokenCommand preserves JWT cache and single-mint behavior", async () => { + const srv = echoServer() + const token = `e30.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url")}.sig` + try { + await using tmp = await tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "token-mints") + await provider(dir, { + baseURL: srv.url, + tokenCommand: `printf x >> ${JSON.stringify(marker)}; printf %s ${JSON.stringify(token)}`, + }) + return marker + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trust() + const model = await Provider.getModel("token-cmd", "m") + const language = await Provider.getLanguage(model) + await generateText({ model: language, prompt: "first" }).catch(() => {}) + await generateText({ model: language, prompt: "second" }).catch(() => {}) + }, + }) + expect(await Bun.file(tmp.extra).text()).toBe("x") + } finally { + srv.stop() + } + expect(srv.seen).toEqual([`Bearer ${token}`, `Bearer ${token}`]) +}) + +test.skipIf(process.platform === "win32")( + "tokenCommand cache and single-flight never cross project or provider authority", + async () => { + const srv = echoServer() + const expires = Math.floor(Date.now() / 1000) + 3600 + const token = (project: string) => + `e30.${Buffer.from(JSON.stringify({ exp: expires, project })).toString("base64url")}.${project}` + const firstToken = token("first") + const secondToken = token("second") + try { + await using first = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "token"), firstToken) + await provider(dir, { baseURL: srv.url, tokenCommand: "cat token" }) + }, + }) + await using second = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "token"), secondToken) + await provider(dir, { baseURL: srv.url, tokenCommand: "cat token" }) + }, + }) + + const request = (directory: string, prompt: string) => + Instance.provide({ + directory, + fn: async () => { + await trust() + const model = await Provider.getModel("token-cmd", "m") + const language = await Provider.getLanguage(model) + await generateText({ model: language, prompt }) + }, + }) + + // Sequential requests exercise the JWT cache. With a command-only key, + // the second project would reuse the first project's long-lived bearer. + await request(first.path, "first sequential") + await request(second.path, "second sequential") + + // Concurrent requests exercise single-flight isolation for the same raw + // command text evaluated in two different project working directories. + Provider.invalidateTokenCache() + await Promise.all([request(first.path, "first concurrent"), request(second.path, "second concurrent")]) + + expect(srv.seen).toHaveLength(4) + expect(srv.seen.filter((value) => value === `Bearer ${firstToken}`)).toHaveLength(2) + expect(srv.seen.filter((value) => value === `Bearer ${secondToken}`)).toHaveLength(2) + } finally { + Provider.invalidateTokenCache() + srv.stop() + } + }, + 30_000, +) diff --git a/backend/cli/test/pty-environment.test.ts b/backend/cli/test/pty-environment.test.ts index 4c107269..c32217ed 100644 --- a/backend/cli/test/pty-environment.test.ts +++ b/backend/cli/test/pty-environment.test.ts @@ -25,6 +25,8 @@ test("project terminals do not inherit the parent macOS terminal session", () => expect(env.OPENSCIENCE_PROJECT_ID).toBe("project_1") expect(env.OPENSCIENCE_SESSION_ID).toBe("ses_1") expect(env.PROMPT).toBe("%n@workstation %1~ %# ") + expect(env.RPROMPT).toBe("") + expect(env.PROMPT_EOL_MARK).toBe("") expect(env.PS1).toBeUndefined() expect(env.TERM_SESSION_ID).toBeUndefined() expect(env.TERM_PROGRAM).toBeUndefined() @@ -41,10 +43,18 @@ test("project terminals show the current workspace folder in common shell prompt "\\u@Aayams-MacBook-Pro-3 \\W \\$ ", ) expect(terminalEnv({}, "project_1", "ses_1", "nu", "workstation.local").PROMPT).toBeUndefined() + expect(terminalEnv({}, "project_1", "ses_1", "C:\\Program Files\\Git\\bin\\bash", "workstation.local")).toMatchObject( + { + PS1: "\\u@workstation \\W \\$ ", + BASH_SILENCE_DEPRECATION_WARNING: "1", + }, + ) }) -test("zsh keeps user startup files but skips the global history override", () => { - expect(terminalArgs("/bin/zsh")).toEqual(["-d", "-l"]) - expect(terminalArgs("/bin/bash")).toEqual(["-l"]) +test("interactive shells start clean without restored sessions or user bootstrap output", () => { + expect(terminalArgs("/bin/zsh")).toEqual(["-d", "-f", "-i"]) + expect(terminalArgs("/bin/bash")).toEqual(["--noprofile", "--norc", "-i"]) + expect(terminalArgs("/usr/local/bin/fish")).toEqual(["--no-config", "--interactive"]) + expect(terminalArgs("/bin/dash")).toEqual(["-i"]) expect(terminalArgs("nu")).toEqual([]) }) diff --git a/backend/cli/test/sandbox/sandbox.test.ts b/backend/cli/test/sandbox/sandbox.test.ts index f1f3d40e..7cdd1ba6 100644 --- a/backend/cli/test/sandbox/sandbox.test.ts +++ b/backend/cli/test/sandbox/sandbox.test.ts @@ -7,18 +7,38 @@ import { tmpdir } from "../fixture/fixture" const shell = "/bin/sh" +async function execute(plan: Sandbox.Plan, cwd: string) { + try { + return await executeWithoutCleanup(plan, cwd) + } finally { + Sandbox.cleanup(plan) + } +} + +async function executeWithoutCleanup(plan: Sandbox.Plan, cwd: string) { + const proc = Bun.spawn([plan.file, ...(plan.args ?? [])], { cwd, stdout: "pipe", stderr: "pipe" }) + const [exit, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + return { exit, stdout, stderr } +} + describe("Sandbox.seatbeltProfile", () => { test("denies writes by default and re-allows the workspace", () => { const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: true }) expect(profile).toContain("(version 1)") - expect(profile).toContain("(allow default)") - expect(profile).toContain("(deny file-write*)") + expect(profile).toContain("(deny default)") + expect(profile).toContain('(import "system.sb")') + expect(profile).not.toContain("(allow default)") expect(profile).toContain('(subpath "/work/project")') }) - test("network:false adds a network deny; network:true does not", () => { - expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: false })).toContain("(deny network*)") - expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: true })).not.toContain("(deny network*)") + test("both policy modes deny all sockets because SBPL cannot filter private CIDR ranges", () => { + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: false })).not.toContain("(allow network*)") + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: true })).not.toContain("(allow network*)") + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: true })).not.toContain("(system-network)") }) test("a path outside the allowlist is not granted write access", () => { @@ -29,8 +49,7 @@ describe("Sandbox.seatbeltProfile", () => { test("adds the macOS /private firmlink alias for /tmp", () => { const profile = Sandbox.seatbeltProfile({ writable: ["/tmp"], network: true }) - expect(profile).toContain('(subpath "/tmp")') - expect(profile).toContain('(subpath "/private/tmp")') + expect(profile).toContain(`(subpath "${fs.realpathSync.native("/tmp")}")`) }) test("escapes quotes in paths so the profile cannot be broken out of", () => { @@ -44,24 +63,51 @@ describe("Sandbox.seatbeltProfile", () => { unreadable: ["/home/user/.config/atlas-cli/config.json"], network: true, }) - expect(profile).toContain('(deny file-read* (literal "/home/user/.config/atlas-cli/config.json"))') + expect(profile).toContain( + `(deny file-read* (literal "${fs.realpathSync.native("/home")}/user/.config/atlas-cli/config.json"))`, + ) + }) + + test("allows resolver traversal only on exact ancestors", () => { + const profile = Sandbox.seatbeltProfile({ + writable: ["/work/project"], + readable: ["/work/project/packages/server"], + readableExact: ["/work/project/packages", "/work/project"], + network: false, + }) + expect(profile).toContain('(literal "/work/project/packages")') + expect(profile).toContain('(literal "/work/project")') + expect(profile).not.toContain('(subpath "/work/project/packages")') }) }) describe("Sandbox.bubblewrapArgs", () => { - test("mounts the fs read-only then re-binds the workspace writable", () => { - const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], network: true }) - expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]) // whole fs read-only first + test("starts from an empty root and mounts only runtimes plus explicit grants", () => { + const args = Sandbox.bubblewrapArgs({ + writable: ["/work/project"], + readable: ["/work/reference"], + network: true, + }) + const hostRoot = args.findIndex( + (value, index) => value === "--ro-bind" && args[index + 1] === "/" && args[index + 2] === "/", + ) + expect(hostRoot).toBe(-1) + expect(args).toContain("/usr") + const readable = args.findIndex( + (value, index) => value === "--ro-bind-try" && args[index + 1] === "/work/reference", + ) + expect(args.slice(readable, readable + 3)).toEqual(["--ro-bind-try", "/work/reference", "/work/reference"]) expect(args).toContain("--die-with-parent") + expect(args).toContain("--new-session") const i = args.indexOf("--bind-try") expect(i).toBeGreaterThan(-1) expect(args[i + 1]).toBe("/work/project") expect(args[i + 2]).toBe("/work/project") }) - test("network:false unshares the network namespace", () => { + test("fails closed to an isolated network namespace in both policy modes", () => { expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: false })).toContain("--unshare-net") - expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).not.toContain("--unshare-net") + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).toContain("--unshare-net") }) test("skips the /tmp tmpfs root but binds workspace paths under it", () => { @@ -69,16 +115,30 @@ describe("Sandbox.bubblewrapArgs", () => { expect(args).toContain("--tmpfs") const binds = args.flatMap((a, n) => (a === "--bind-try" ? [args[n + 1]!] : [])) // the /tmp mount root itself is never bound from the host (the tmpfs provides it) - expect(binds).not.toContain("/tmp") + const tmp = fs.realpathSync.native("/tmp") + expect(binds).not.toContain(tmp) // ...but a workspace living under /tmp must still be bound on top of the tmpfs, // otherwise its writes vanish into the throwaway tmpfs - expect(binds).toContain("/tmp/sub") + expect(binds).toContain(path.join(tmp, "sub")) }) test("unshares the PID namespace so /proc escape vectors are closed", () => { expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).toContain("--unshare-pid") }) + test("does not implicitly expose Linux user-data roots", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], network: false }) + const sources = args.flatMap((value, index) => + value === "--ro-bind" || value === "--ro-bind-try" || value === "--bind" || value === "--bind-try" + ? [args[index + 1]!] + : [], + ) + expect(sources).not.toContain("/") + expect(sources).not.toContain("/home") + expect(sources).not.toContain("/root") + expect(sources).not.toContain("/var") + }) + test("masks host credential files with an empty device", () => { const file = path.join(os.tmpdir(), `openscience-sandbox-secret-${process.pid}`) fs.writeFileSync(file, "secret") @@ -89,7 +149,7 @@ describe("Sandbox.bubblewrapArgs", () => { network: true, }) const mask = args.findIndex((value, index) => value === "--ro-bind-try" && args[index + 1] === "/dev/null") - expect(args.slice(mask, mask + 3)).toEqual(["--ro-bind-try", "/dev/null", file]) + expect(args.slice(mask, mask + 3)).toEqual(["--ro-bind-try", "/dev/null", fs.realpathSync.native(file)]) } finally { fs.rmSync(file, { force: true }) } @@ -106,6 +166,19 @@ describe("Sandbox.bubblewrapArgs", () => { expect(args).not.toContain(file) }) + test("covers an existing credential directory with an empty tmpfs", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `openscience-sandbox-credentials-${process.pid}-`)) + try { + const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], unreadable: [directory], network: true }) + const mask = args.findIndex( + (value, index) => value === "--tmpfs" && args[index + 1] === fs.realpathSync.native(directory), + ) + expect(mask).toBeGreaterThan(-1) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + test.skipIf(Sandbox.backend() !== "bubblewrap")("produces an argv bwrap actually accepts", async () => { await using tmp = await tmpdir() const present = path.join(tmp.path, "auth.json") @@ -130,6 +203,79 @@ describe("Sandbox.bubblewrapArgs", () => { expect(exit, error).toBe(0) expect(out.trim()).toBe("ok") }) + + test.skipIf(Sandbox.backend() !== "bubblewrap")( + "keeps a setsid double-fork inside the PID namespace and kills it with the wrapper", + async () => { + if (!Bun.which("python3")) return + await using tmp = await tmpdir() + const marker = path.join(tmp.path, "double-fork.pid") + const script = [ + "import os,time", + "os.fork() and os._exit(0)", + "os.setsid()", + "os.fork() and os._exit(0)", + `open(${JSON.stringify(marker)}, 'w').write('ready')`, + "time.sleep(3600)", + ].join(";") + const plan = Sandbox.plan({ + command: `python3 -c ${JSON.stringify(script)}`, + shell, + cwd: tmp.path, + workspace: [tmp.path], + options: { enabled: true, network: "deny", onUnavailable: "error" }, + }) + const proc = Bun.spawn([plan.file, ...(plan.args ?? [])], { + cwd: tmp.path, + stdout: "ignore", + stderr: "pipe", + }) + const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + } + const hostPID = () => + fs + .readdirSync("/proc") + .filter((value) => /^\d+$/.test(value)) + .map(Number) + .find((pid) => { + try { + const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean) + return path.basename(argv[0] ?? "").startsWith("python") && argv.some((value) => value.includes(marker)) + } catch { + return false + } + }) + const escaped = { pid: 0 } + try { + for (let attempt = 0; attempt < 300 && !fs.existsSync(marker); attempt++) await Bun.sleep(10) + expect(fs.existsSync(marker)).toBe(true) + escaped.pid = hostPID() ?? 0 + expect(escaped.pid).toBeGreaterThan(0) + expect(alive(escaped.pid)).toBe(true) + + // The initial Python process has exited twice, but bwrap's namespace + // reaper still owns the daemon and therefore has not reported exit. + expect(proc.exitCode).toBeNull() + proc.kill("SIGKILL") + await proc.exited + for (let attempt = 0; attempt < 300 && alive(escaped.pid); attempt++) await Bun.sleep(10) + expect(alive(escaped.pid)).toBe(false) + } finally { + if (proc.exitCode === null) { + proc.kill("SIGKILL") + await proc.exited + } + if (escaped.pid && alive(escaped.pid)) process.kill(escaped.pid, "SIGKILL") + Sandbox.cleanup(plan) + } + }, + ) }) describe("Sandbox.backend/describe", () => { @@ -140,6 +286,7 @@ describe("Sandbox.backend/describe", () => { expect(d.platform).toBe(process.platform) if (d.available) expect(d.tool).toBeTruthy() else expect(d.reason).toBeTruthy() + if (d.available) expect(d.networkIsolation).toBe("deny_all") }) }) @@ -168,6 +315,8 @@ describe("Sandbox.plan", () => { // the actual shell command lives at the tail of the argv expect(p.args).toContain("echo hi") expect(p.args).toContain(shell) + expect(p.temporary).toBeTruthy() + expect(p.args).toContain(`TMPDIR=${p.temporary}`) } else { expect(p.sandboxed).toBe(false) } @@ -211,4 +360,251 @@ describe("Sandbox.plan", () => { // nor $HOME itself expect(argv).not.toContain(`(subpath "${os.homedir()}")`) }) + + test("does not expose the user's home when PATH itself contains that broad root", () => { + if (!Sandbox.available()) return + const before = process.env.PATH + process.env.PATH = `${os.homedir()}${path.delimiter}/usr/bin` + try { + const plan = Sandbox.plan({ + ...base, + options: { enabled: true, network: "deny" }, + }) + try { + const argv = plan.args ?? [] + expect(argv).not.toContain(os.homedir()) + expect(argv.join(" ")).not.toContain(`(subpath "${os.homedir()}")`) + } finally { + Sandbox.cleanup(plan) + } + } finally { + if (before === undefined) delete process.env.PATH + else process.env.PATH = before + } + }) + + test("rejects relative, broken-symlink, and over-broad writable grants", () => { + expect(Sandbox.writableGrant("relative/path")).toBeUndefined() + expect(Sandbox.writableGrant("/")).toBeUndefined() + expect(Sandbox.writableGrant(os.homedir())).toBeUndefined() + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-policy-path-")) + try { + expect(Sandbox.writableGrant(path.join(root, "future", "results"))).toBe( + path.join(fs.realpathSync.native(root), "future", "results"), + ) + if (process.platform !== "win32") { + const broken = path.join(root, "broken") + fs.symlinkSync(path.join(root, "missing"), broken) + expect(Sandbox.writableGrant(broken)).toBeUndefined() + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) + +describe("Sandbox native isolation", () => { + test.skipIf(!Sandbox.available())( + "enforces separate canonical read and write grants and blocks symlink escapes", + async () => { + const root = fs.mkdtempSync(path.join(os.homedir(), ".openscience-read-grants-")) + const work = path.join(root, "work") + const readonly = path.join(root, "readonly") + const secret = path.join(root, "secret.txt") + fs.mkdirSync(work) + fs.mkdirSync(readonly) + fs.writeFileSync(path.join(readonly, "data.txt"), "granted") + fs.writeFileSync(secret, "secret") + fs.symlinkSync(secret, path.join(work, "escape")) + const options = { enabled: true, network: "deny" as const, onUnavailable: "error" as const } + + try { + const granted = Sandbox.plan({ + command: `cat "${path.join(readonly, "data.txt")}"`, + shell, + cwd: work, + workspace: [work], + readable: [readonly], + options, + }) + expect(await execute(granted, work)).toMatchObject({ exit: 0, stdout: "granted" }) + + const masked = Sandbox.plan({ + command: `cat "${path.join(readonly, "data.txt")}"`, + shell, + cwd: work, + workspace: [work], + readable: [readonly], + unreadable: [path.join(readonly, "data.txt")], + options, + }) + expect((await execute(masked, work)).exit).not.toBe(0) + + const mutate = Sandbox.plan({ + command: `printf changed > "${path.join(readonly, "data.txt")}"`, + shell, + cwd: work, + workspace: [work], + readable: [readonly], + options, + }) + expect((await execute(mutate, work)).exit).not.toBe(0) + expect(fs.readFileSync(path.join(readonly, "data.txt"), "utf8")).toBe("granted") + + const ungranted = Sandbox.plan({ + command: `cat "${secret}"`, + shell, + cwd: work, + workspace: [work], + readable: [readonly], + options, + }) + expect((await execute(ungranted, work)).exit).not.toBe(0) + + const escaped = Sandbox.plan({ + command: `cat "${path.join(work, "escape")}"`, + shell, + cwd: work, + workspace: [work], + options, + }) + expect((await execute(escaped, work)).exit).not.toBe(0) + + const broken = path.join(root, "broken") + fs.symlinkSync(path.join(root, "missing"), broken) + const ambiguous = Sandbox.plan({ + command: "true", + shell, + cwd: work, + workspace: [work], + readable: [broken], + options, + }) + expect((ambiguous.args ?? []).join(" ")).not.toContain(broken) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, + ) + + test.skipIf(!Sandbox.available())("does not expose sibling files in the user's temp directory", async () => { + await using tmp = await tmpdir() + const sibling = path.join(os.tmpdir(), `.openscience-sandbox-sibling-${process.pid}`) + fs.writeFileSync(sibling, "private sibling", { mode: 0o600 }) + try { + const plan = Sandbox.plan({ + command: `cat "${sibling}"`, + shell, + cwd: tmp.path, + workspace: [tmp.path], + options: { enabled: true, network: "deny", onUnavailable: "error" }, + }) + expect((await execute(plan, tmp.path)).exit).not.toBe(0) + const argv = (plan.args ?? []).join(" ") + expect(plan.temporary).toBeTruthy() + expect(argv).toContain(plan.temporary!) + expect(argv).not.toContain(`(subpath "${fs.realpathSync.native(os.tmpdir())}")`) + } finally { + fs.rmSync(sibling, { force: true }) + } + }) + + test.skipIf(!Sandbox.available())( + "isolates unique temp roots between parallel sandbox plans and cleans them", + async () => { + await using firstWorkspace = await tmpdir() + await using secondWorkspace = await tmpdir() + const options = { enabled: true, network: "deny", onUnavailable: "error" } as const + const first = Sandbox.plan({ + command: 'sleep 0.2; cat "$TMPDIR/owned"', + shell, + cwd: firstWorkspace.path, + workspace: [firstWorkspace.path], + options, + }) + expect(first.temporary).toBeTruthy() + fs.writeFileSync(path.join(first.temporary!, "owned"), "first-only", { mode: 0o600 }) + const second = Sandbox.plan({ + command: `cat "${path.join(first.temporary!, "owned")}"`, + shell, + cwd: secondWorkspace.path, + workspace: [secondWorkspace.path], + options, + }) + expect(second.temporary).toBeTruthy() + expect(second.temporary).not.toBe(first.temporary) + try { + const [own, sibling] = await Promise.all([ + executeWithoutCleanup(first, firstWorkspace.path), + executeWithoutCleanup(second, secondWorkspace.path), + ]) + expect(own.exit, own.stderr).toBe(0) + expect(own.stdout.trim()).toBe("first-only") + expect(sibling.exit).not.toBe(0) + } finally { + const firstTemp = first.temporary! + const secondTemp = second.temporary! + Sandbox.cleanup(first) + Sandbox.cleanup(second) + expect(fs.existsSync(firstTemp)).toBe(false) + expect(fs.existsSync(secondTemp)).toBe(false) + } + }, + ) + + test.skipIf(!Sandbox.available())("hides sibling host processes", async () => { + await using tmp = await tmpdir() + const sibling = Bun.spawn(["/bin/sleep", "10"], { stdout: "ignore", stderr: "ignore" }) + try { + const control = Bun.spawn(["/bin/ps", "-p", String(sibling.pid), "-o", "pid="], { + stdout: "pipe", + stderr: "pipe", + }) + expect((await new Response(control.stdout).text()).trim()).toBe(String(sibling.pid)) + expect(await control.exited).toBe(0) + + const plan = Sandbox.plan({ + command: `/bin/ps -p ${sibling.pid} -o pid=`, + shell, + cwd: tmp.path, + workspace: [tmp.path], + options: { enabled: true, network: "deny", onUnavailable: "error" }, + }) + const isolated = await execute(plan, tmp.path) + expect(isolated.exit).not.toBe(0) + expect(isolated.stdout.trim()).toBe("") + } finally { + sibling.kill() + await sibling.exited + } + }) + + test.skipIf(!Sandbox.available())("blocks loopback and the host LAN interface in both policy modes", async () => { + if (!Bun.which("curl")) return + await using tmp = await tmpdir() + const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch: () => new Response("local endpoint") }) + const lan = Object.values(os.networkInterfaces()) + .flat() + .find((address) => address?.family === "IPv4" && !address.internal)?.address + const targets = [`http://127.0.0.1:${server.port}`, ...(lan ? [`http://${lan}:${server.port}`] : [])] + try { + for (const target of targets) { + expect(await fetch(target).then((response) => response.text())).toBe("local endpoint") + } + for (const network of ["allow", "deny"] as const) { + for (const target of targets) { + const plan = Sandbox.plan({ + command: `curl -m 2 -sS "${target}"`, + shell, + cwd: tmp.path, + workspace: [tmp.path], + options: { enabled: true, network, onUnavailable: "error" }, + }) + expect((await execute(plan, tmp.path)).exit).not.toBe(0) + } + } + } finally { + server.stop(true) + } + }) }) diff --git a/backend/cli/test/science/connector-ratelimit.test.ts b/backend/cli/test/science/connector-ratelimit.test.ts index 002f7307..7378f77c 100644 --- a/backend/cli/test/science/connector-ratelimit.test.ts +++ b/backend/cli/test/science/connector-ratelimit.test.ts @@ -1,21 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { clearCache, resetRateLimits } from "../../src/science/connectors/http" +import { beforeEach, describe, expect, test } from "bun:test" +import { clearCache, resetRateLimits, withHttpTestPolicy } from "../../src/science/connectors/http" import { semanticScholar } from "../../src/science/connectors/literature/semantic-scholar" import { dbsnp } from "../../src/science/connectors/genomics/dbsnp" import { pubmed } from "../../src/science/connectors/literature/pubmed" import { geo } from "../../src/science/connectors/omics/geo" -const realFetch = globalThis.fetch +const publicResolution = async () => ["93.184.216.34"] beforeEach(() => { clearCache() resetRateLimits() }) -afterEach(() => { - globalThis.fetch = realFetch -}) - // science_fetch makes back-to-back record retrieval an ordinary action, and a // second full pass over the connector set trips Semantic Scholar's keyless // limiter. These assertions are on observed pacing, not on source text: the @@ -24,45 +20,91 @@ afterEach(() => { // // Every call below uses a DISTINCT id. The http cache is keyed by `${method} ${url}` // (http.ts:164), so identical ids would be served from cache and never paced. +// Request starts are recorded on the monotonic clock: wall time can be adjusted +// independently by the OS or Bun's setSystemTime() in another backend test, while +// the timer that enforces the interval continues to advance monotonically. The +// scoped policy also prevents concurrent test files from replacing global fetch +// or resetting the limiter underneath these requests. describe("rate limits on the hosts that need them", () => { test("semantic-scholar paces successive requests about a second apart", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ paperId: "x", title: "t" }), { status: 200 })) as unknown as typeof fetch - const started = Date.now() - await semanticScholar.fetch("1111111111111111111111111111111111111111") - await semanticScholar.fetch("2222222222222222222222222222222222222222") - expect(Date.now() - started).toBeGreaterThanOrEqual(900) + const starts: number[] = [] + await withHttpTestPolicy( + { + resolveAddresses: publicResolution, + transport: async () => { + starts.push(performance.now()) + return new Response(JSON.stringify({ paperId: "x", title: "t" }), { status: 200 }) + }, + }, + async () => { + await semanticScholar.fetch("1111111111111111111111111111111111111111") + await semanticScholar.fetch("2222222222222222222222222222222222222222") + }, + ) + expect(starts).toHaveLength(2) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(900) }) - // Prime the shared host's limiter with one paced request, then time the target - // consumer in isolation. An UNPACED consumer returns immediately; a paced one - // must wait out the 350ms interval. Timing each separately is what lets this - // fail when exactly ONE consumer loses its rateLimit — a single cumulative - // measurement cannot attribute the delay to any particular consumer. + // Prime the shared host's limiter with one paced request, then observe request + // START times inside the transport. The contract spaces starts, so measuring + // only after the prime response returns wrongly subtracts DNS/response time + // from the expected interval and becomes load-dependent in the full suite. test("geo is paced against the shared eutils host", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 })) as unknown as typeof fetch - await dbsnp.fetch("rs334") - const started = Date.now() - await geo.fetch("GSE1000") - expect(Date.now() - started).toBeGreaterThanOrEqual(300) + const starts: number[] = [] + await withHttpTestPolicy( + { + resolveAddresses: publicResolution, + transport: async () => { + starts.push(performance.now()) + return new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 }) + }, + }, + async () => { + await dbsnp.fetch("rs334") + await geo.fetch("GSE1000") + }, + ) + expect(starts).toHaveLength(2) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(300) }) test("pubmed is paced against the shared eutils host", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 })) as unknown as typeof fetch - await dbsnp.fetch("rs1801133") - const started = Date.now() - await pubmed.fetch("10508479") - expect(Date.now() - started).toBeGreaterThanOrEqual(300) + const starts: number[] = [] + await withHttpTestPolicy( + { + resolveAddresses: publicResolution, + transport: async () => { + starts.push(performance.now()) + return new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 }) + }, + }, + async () => { + await dbsnp.fetch("rs1801133") + await pubmed.fetch("10508479") + }, + ) + expect(starts).toHaveLength(3) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(300) + expect(starts[2]! - starts[1]!).toBeGreaterThanOrEqual(300) }) test("the eutils module itself is paced", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 })) as unknown as typeof fetch - await pubmed.fetch("9999999") - const started = Date.now() - await dbsnp.fetch("rs429358") - expect(Date.now() - started).toBeGreaterThanOrEqual(300) + const starts: number[] = [] + await withHttpTestPolicy( + { + resolveAddresses: publicResolution, + transport: async () => { + starts.push(performance.now()) + return new Response(JSON.stringify({ result: { uids: [] } }), { status: 200 }) + }, + }, + async () => { + await pubmed.fetch("9999999") + await dbsnp.fetch("rs429358") + }, + ) + expect(starts).toHaveLength(3) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(300) + expect(starts[2]! - starts[1]!).toBeGreaterThanOrEqual(300) }) }) diff --git a/backend/cli/test/science/http.test.ts b/backend/cli/test/science/http.test.ts index 60a6df64..08108859 100644 --- a/backend/cli/test/science/http.test.ts +++ b/backend/cli/test/science/http.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { getJSON, getText, request, clearCache, resetRateLimits, orFallback } from "../../src/science/connectors/http" +import { + getJSON as getJSONRaw, + getText as getTextRaw, + request as requestRaw, + clearCache, + resetRateLimits, + orFallback, + type HttpOptions, +} from "../../src/science/connectors/http" import { Network } from "../../src/settings/network" // The shared http helper is the ONLY reliability layer under science/connectors, @@ -7,10 +15,16 @@ import { Network } from "../../src/settings/network" // negative-cache rules, content negotiation, and the per-host throttle. const realFetch = globalThis.fetch +const publicResolution = async () => ["93.184.216.34"] +const withResolution = (opts: HttpOptions = {}): HttpOptions => ({ ...opts, resolveAddresses: publicResolution }) +const getText = (url: string, opts?: HttpOptions) => getTextRaw(url, withResolution(opts)) +const getJSON = (url: string, opts?: HttpOptions) => getJSONRaw(url, withResolution(opts)) +const request = (url: string, opts?: HttpOptions) => requestRaw(url, withResolution(opts)) -beforeEach(() => { +beforeEach(async () => { clearCache() resetRateLimits() + await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) }) afterEach(async () => { @@ -126,6 +140,18 @@ describe("http network allow-list", () => { await expect(getText("https://blocked.test/a")).rejects.toThrow("allow-list") expect(calls).toBe(0) }) + + test("blocks a redirect to a disallowed host before following it", async () => { + let calls = 0 + globalThis.fetch = (async () => { + calls++ + return new Response(null, { status: 302, headers: { Location: "https://blocked.test/private" } }) + }) as unknown as typeof fetch + await Network.set({ allowlistEnabled: true, enabled: [], custom: ["allowed.test"] }) + + await expect(getText("https://allowed.test/start", { retries: 0 })).rejects.toThrow("blocked.test") + expect(calls).toBe(1) + }) }) describe("http per-host throttle", () => { diff --git a/backend/cli/test/science/kernel-lease.test.ts b/backend/cli/test/science/kernel-lease.test.ts new file mode 100644 index 00000000..dcf19031 --- /dev/null +++ b/backend/cli/test/science/kernel-lease.test.ts @@ -0,0 +1,596 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { KernelProcessIdentity } from "../../src/science/kernel/process" + +// This launches two real servers and waits for native ownership registration, +// lease arbitration, and verified teardown; it is not a 5s unit operation. +test("two servers cannot start the same persistent kernel identity", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-lease-")) + const workspace = path.join(root, "workspace") + const runner = path.join(root, "kernel.ts") + const registry = new URL("../../src/science/kernel/registry.ts", import.meta.url).href + const processModule = new URL("../../src/science/kernel/process.ts", import.meta.url).href + const launcher = new URL("../../src/process/windows-job-launcher.ts", import.meta.url).href + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + const marker = path.join(root, "starts.log") + await fs.mkdir(workspace) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { KernelRuntime } from ${JSON.stringify(registry)} + import { KernelProcessIdentity } from ${JSON.stringify(processModule)} + import { WindowsJobLauncher } from ${JSON.stringify(launcher)} +import { Instance } from ${JSON.stringify(instance)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +await Instance.provide({ directory: process.argv[2], fn: async () => { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + if (process.argv[3] === "setup") { + console.log((await Session.create({})).id) + return + } + const kernels = new Map() + KernelRuntime.register({ + language: "lease-test", + async get(id, options) { + const existing = kernels.get(id) + if (existing) return existing + await fs.appendFile(${JSON.stringify(marker)}, "start\\n") + const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined + const identity = await KernelProcessIdentity.register(child, ownership) + if (!identity) throw new Error("Kernel child exited before registration") + const kernel = { + id, + language: "lease-test", + ready: true, + process: identity, + async start() {}, + async execute() { return { ok: true, outputs: [], stdout: "", stderr: "" } }, + async shutdown() { await KernelProcessIdentity.terminate(identity) }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id) { + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + const identity = { + projectID: Instance.project.id, + sessionID: process.argv[4], + name: "shared", + language: "lease-test", + } + await KernelRuntime.get(identity) + await Bun.sleep(1_800) + await KernelRuntime.release(identity) +} }) +`, + ) + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } + + try { + const setup = Bun.spawn([process.execPath, runner, workspace, "setup"], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + + const first = Bun.spawn([process.execPath, runner, workspace, "run", sessionID.trim()], { + env, + stdout: "pipe", + stderr: "pipe", + }) + await Bun.sleep(100) + const second = Bun.spawn([process.execPath, runner, workspace, "run", sessionID.trim()], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const results = await Promise.all( + [first, second].map(async (proc) => ({ + code: await proc.exited, + error: await new Response(proc.stderr).text(), + })), + ) + expect(results.filter((item) => item.code === 0)).toHaveLength(1) + expect(results.find((item) => item.code !== 0)?.error).toContain("active in another OpenScience server") + expect((await fs.readFile(marker, "utf8")).trim().split("\n")).toEqual(["start"]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("revocation reclaims identity-verified kernels orphaned by a killed server", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-revocation-")) + const workspace = path.join(root, "workspace") + const runner = path.join(root, "kernel.ts") + const registry = new URL("../../src/science/kernel/registry.ts", import.meta.url).href + const processModule = new URL("../../src/science/kernel/process.ts", import.meta.url).href + const launcher = new URL("../../src/process/windows-job-launcher.ts", import.meta.url).href + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + const markers = path.join(root, "owners") + await fs.mkdir(workspace) + await fs.mkdir(markers) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import path from "node:path" +import { KernelRuntime } from ${JSON.stringify(registry)} + import { KernelProcessIdentity } from ${JSON.stringify(processModule)} + import { WindowsJobLauncher } from ${JSON.stringify(launcher)} +import { Instance } from ${JSON.stringify(instance)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +await Instance.provide({ directory: process.argv[2], fn: async () => { + const mode = process.argv[3] + if (mode === "setup") { + console.log((await Session.create({})).id) + return + } + const kernels = new Map() + KernelRuntime.register({ + language: "revocation-test", + async get(id, options) { + const existing = kernels.get(id) + if (existing) return existing + const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined + const identity = await KernelProcessIdentity.register(child, ownership) + if (!identity) throw new Error("Kernel child exited before registration") + const kernel = { + id, + language: "revocation-test", + ready: true, + process: identity, + async start() {}, + async execute() { return { ok: true, outputs: [], stdout: "", stderr: "" } }, + async shutdown() { await KernelProcessIdentity.terminate(identity) }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id) { + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + if (mode === "owner") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + const kernel = await KernelRuntime.get({ + projectID: Instance.project.id, + sessionID: process.argv[4], + name: process.argv[5], + language: "revocation-test", + }) + await fs.writeFile(path.join(${JSON.stringify(markers)}, process.argv[5] + ".json"), JSON.stringify(kernel.process)) + await new Promise(() => {}) + } + if (mode === "release-project") await KernelRuntime.releaseProject(Instance.project.id) + if (mode === "remove-session") await KernelRuntime.removeSession(Instance.project.id, process.argv[4]) + if (mode === "dispose") { + await KernelRuntime.restoreSession(Instance.project.id, process.argv[4]) + await Instance.dispose() + } +} }) +`, + ) + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } + const owners = new Set>() + const identities: { pid: number; startedAt: number; token?: string }[] = [] + const read = async (name: string, attempt = 0): Promise<(typeof identities)[number]> => { + const value = await Bun.file(path.join(markers, `${name}.json`)) + .json() + .catch(() => undefined) + if (value) return value as (typeof identities)[number] + if (attempt >= 200) throw new Error(`Timed out waiting for ${name} to publish its kernel identity`) + await Bun.sleep(25) + return read(name, attempt + 1) + } + const gone = async (identity: (typeof identities)[number], attempt = 0): Promise => { + if (!KernelProcessIdentity.matchesRecorded(identity)) return true + if (attempt >= 200) return false + await Bun.sleep(10) + return gone(identity, attempt + 1) + } + const invoke = async (mode: string, sessionID: string) => { + const proc = Bun.spawn([process.execPath, runner, workspace, mode, sessionID], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const [code, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + return { code, error } + } + + try { + const setup = Bun.spawn([process.execPath, runner, workspace, "setup"], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + + const actions = [ + ["release-project", "project-orphan"], + ["remove-session", "session-orphan"], + ["dispose", "instance-orphan"], + ] as const + for (const [action, name] of actions) { + const owner = Bun.spawn([process.execPath, runner, workspace, "owner", sessionID.trim(), name], { + env, + stdout: "ignore", + stderr: "pipe", + }) + owners.add(owner) + const identity = await read(name) + identities.push(identity) + expect(identity.token).toBeDefined() + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(true) + + if (action === "release-project") { + const live = await invoke(action, sessionID.trim()) + expect(live.code).not.toBe(0) + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(true) + } + + owner.kill("SIGKILL") + await owner.exited + owners.delete(owner) + if (process.platform === "darwin") expect(await gone(identity)).toBe(true) + else expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(true) + + const revoked = await invoke(action, sessionID.trim()) + expect(revoked.code, revoked.error).toBe(0) + expect(await gone(identity)).toBe(true) + } + } finally { + for (const owner of owners) { + owner.kill("SIGKILL") + await owner.exited.catch(() => undefined) + } + await Promise.all(identities.map((identity) => KernelProcessIdentity.terminate(identity))) + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("a fresh server reaps surviving kernel children after their recorded leader exits", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-leader-exit-")) + const workspace = path.join(root, "workspace") + const fixture = path.resolve(import.meta.dir, "../fixture/kernel-leader-exit.ts") + const ready = path.join(root, "ready.json") + const childFile = path.join(root, "child.pid") + const releaseFile = path.join(root, "release") + await fs.mkdir(workspace) + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } + let owner: ReturnType | undefined + let child: { pid: number; identity: string } | undefined + const waitJson = async (file: string, attempt = 0): Promise => { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value) return value as T + if (attempt >= 500) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return waitJson(file, attempt + 1) + } + const gone = async (target: { pid: number; identity: string }, attempt = 0): Promise => { + if (!(await AuthorityProcessLedger.owns(target.pid, target.identity))) return true + if (attempt >= 300) return false + await Bun.sleep(10) + return gone(target, attempt + 1) + } + const invoke = async (...args: string[]) => { + const proc = Bun.spawn([process.execPath, fixture, workspace, ...args], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + return { code, stdout, stderr } + } + + try { + const setup = await invoke("setup") + expect(setup.code, setup.stderr).toBe(0) + const sessionID = setup.stdout.trim() + owner = Bun.spawn([process.execPath, fixture, workspace, "owner", sessionID, ready, childFile, releaseFile], { + env, + stdout: "ignore", + stderr: "pipe", + }) + const published = await waitJson<{ + process: { pid: number; startedAt: number; token?: string; ownershipID?: string } + childPID: number + }>(ready) + expect(published.process.token).toHaveLength(64) + expect(published.process.ownershipID).toStartWith("kernel-") + const childIdentity = await AuthorityProcessLedger.identity(published.childPID) + expect(childIdentity).toBeDefined() + child = { pid: published.childPID, identity: childIdentity! } + expect(await AuthorityProcessLedger.owns(child.pid, child.identity)).toBe(true) + + await Bun.write(releaseFile, "release") + const leaderGone = async (attempt = 0): Promise => { + if (!KernelProcessIdentity.matchesRecorded(published.process)) return true + if (attempt >= 300) return false + await Bun.sleep(10) + return leaderGone(attempt + 1) + } + expect(await leaderGone()).toBe(true) + expect(await AuthorityProcessLedger.owns(child.pid, child.identity)).toBe(process.platform !== "darwin") + + owner.kill("SIGKILL") + await owner.exited + owner = undefined + const removed = await invoke("remove", sessionID) + expect(removed.code, removed.stderr).toBe(0) + expect(await gone(child)).toBe(true) + expect(await Bun.file(path.join(root, "data", "authority-processes.json")).json()).toEqual([]) + } finally { + owner?.kill("SIGKILL") + await owner?.exited.catch(() => undefined) + if (child && (await AuthorityProcessLedger.owns(child.pid, child.identity))) { + process.kill(child.pid, "SIGKILL") + } + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("a cross-process trust revocation requested before spawn cannot leave an executable kernel", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-authority-race-")) + const workspace = path.join(root, "workspace") + const runner = path.join(root, "kernel.ts") + const registry = new URL("../../src/science/kernel/registry.ts", import.meta.url).href + const processModule = new URL("../../src/science/kernel/process.ts", import.meta.url).href + const launcher = new URL("../../src/process/windows-job-launcher.ts", import.meta.url).href + const authority = new URL("../../src/project/authority-signal.ts", import.meta.url).href + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + const entered = path.join(root, "spawn-entered") + const release = path.join(root, "spawn-release") + const requested = path.join(root, "revoke-requested") + const acknowledged = path.join(root, "revoke-acknowledged") + const ready = path.join(root, "owner-ready.json") + const execute = path.join(root, "execute") + const result = path.join(root, "result") + await fs.mkdir(workspace) + await Bun.write( + runner, + ` +import fs from "node:fs/promises" +import { KernelRuntime } from ${JSON.stringify(registry)} + import { KernelProcessIdentity } from ${JSON.stringify(processModule)} + import { WindowsJobLauncher } from ${JSON.stringify(launcher)} +import { AuthoritySignal } from ${JSON.stringify(authority)} +import { Instance } from ${JSON.stringify(instance)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +const wait = async (file, attempt = 0) => { + if (await Bun.file(file).exists()) return + if (attempt >= 400) throw new Error("Timed out waiting for " + file) + await Bun.sleep(10) + return wait(file, attempt + 1) +} +await Instance.provide({ directory: process.argv[2], fn: async () => { + const mode = process.argv[3] + if (mode === "setup") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + console.log((await Session.create({})).id) + return + } + if (mode === "revoke") { + await fs.writeFile(${JSON.stringify(requested)}, "requested") + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: false, root: status.root }) + await fs.writeFile(${JSON.stringify(acknowledged)}, "acknowledged") + return + } + const kernels = new Map() + KernelRuntime.register({ + language: "authority-race-test", + async get(id, options) { + await fs.writeFile(${JSON.stringify(entered)}, "entered") + await wait(${JSON.stringify(release)}) + const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined + const identity = await KernelProcessIdentity.register(child, ownership) + if (!identity) throw new Error("Kernel child exited before registration") + const kernel = { + id, + language: "authority-race-test", + ready: true, + process: identity, + async start() {}, + async execute() { return { ok: true, outputs: [], stdout: "", stderr: "" } }, + async shutdown() { await KernelProcessIdentity.terminate(identity) }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id) { + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + const identity = { + projectID: Instance.project.id, + sessionID: process.argv[4], + name: "authority-race", + language: "authority-race-test", + } + const watcher = await AuthoritySignal.watch(async (change) => { + if (change.type !== "event" || change.event.kind !== "trust" || !change.event.denied) return + await KernelRuntime.releaseProject(Instance.project.id) + }, 10) + const kernel = await KernelRuntime.get(identity) + await fs.writeFile(${JSON.stringify(ready)}, JSON.stringify(kernel.process)) + await wait(${JSON.stringify(execute)}) + const outcome = await KernelRuntime.execute(identity, "1").then( + () => "accepted", + () => "denied", + ) + await fs.writeFile(${JSON.stringify(result)}, outcome) + const stopped = async (attempt = 0) => { + if (!KernelProcessIdentity.matchesRecorded(kernel.process)) return + if (attempt >= 400) throw new Error("Revoked kernel was not stopped") + await Bun.sleep(10) + return stopped(attempt + 1) + } + await stopped() + await watcher[Symbol.asyncDispose]() +} }) +`, + ) + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } + const wait = async (file: string, attempt = 0): Promise => { + if (await Bun.file(file).exists()) return + if (attempt >= 400) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return wait(file, attempt + 1) + } + const processes = new Set>() + const identities: { pid: number; startedAt: number; token?: string }[] = [] + + try { + const setup = Bun.spawn([process.execPath, runner, workspace, "setup"], { + env, + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + + const owner = Bun.spawn([process.execPath, runner, workspace, "owner", sessionID.trim()], { + env, + stdout: "pipe", + stderr: "pipe", + }) + processes.add(owner) + await wait(entered) + + const revoker = Bun.spawn([process.execPath, runner, workspace, "revoke"], { + env, + stdout: "pipe", + stderr: "pipe", + }) + processes.add(revoker) + await wait(requested) + await Bun.sleep(300) + expect(await Bun.file(acknowledged).exists()).toBe(false) + await Bun.write(release, "release") + await wait(ready) + const identity = (await Bun.file(ready).json()) as (typeof identities)[number] + identities.push(identity) + expect(identity.token).toBeDefined() + + const [revokeCode, revokeError] = await Promise.all([revoker.exited, new Response(revoker.stderr).text()]) + processes.delete(revoker) + expect(revokeCode, revokeError).toBe(0) + expect(await Bun.file(acknowledged).exists()).toBe(true) + await Bun.write(execute, "execute") + await wait(result) + expect(await Bun.file(result).text()).toBe("denied") + + const [ownerCode, ownerError] = await Promise.all([owner.exited, new Response(owner.stderr).text()]) + processes.delete(owner) + expect(ownerCode, ownerError).toBe(0) + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(false) + } finally { + for (const proc of processes) { + proc.kill("SIGKILL") + await proc.exited.catch(() => undefined) + } + await Promise.all(identities.map((identity) => KernelProcessIdentity.terminate(identity))) + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/backend/cli/test/science/kernel-process-order.test.ts b/backend/cli/test/science/kernel-process-order.test.ts new file mode 100644 index 00000000..ce076ac0 --- /dev/null +++ b/backend/cli/test/science/kernel-process-order.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const posixTest = process.platform === "win32" ? test.skip : test +const fixture = path.resolve(import.meta.dir, "../fixture/kernel-built-in-setsid.ts") + +async function scenario(language: "python" | "r") { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `openscience-${language}-kernel-setsid-`)) + const workspace = path.join(root, "workspace") + const marker = path.join(root, "descendant.pid") + const config = path.join(root, "config") + await Promise.all([fs.mkdir(workspace), fs.mkdir(config)]) + await fs.writeFile(path.join(config, "config.json"), JSON.stringify({ sandbox: { enabled: false } })) + try { + const proc = Bun.spawn([process.execPath, fixture, workspace, language, marker], { + env: { + ...process.env, + OPENSCIENCE_CONFIG_CONTENT: JSON.stringify({ sandbox: { enabled: false } }), + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: config, + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + }, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + expect(code, stderr).toBe(0) + return JSON.parse(stdout.trim()) as { + kernelPID: number + childPID: number + childPPID: number + childPGID: number + childAncestors: number[] + survived: boolean + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +} + +posixTest( + "built-in Python release reaps a direct start_new_session child before killing the kernel leader", + async () => { + const result = await scenario("python") + // On Darwin the durable responsibility supervisor is the recorded kernel + // leader and the Python interpreter is its direct payload child. The + // start_new_session worker must remain in that authenticated ancestry even + // though it is no longer necessarily a direct child of the ledger leader. + expect(result.childAncestors).toContain(result.kernelPID) + expect(result.childPGID).toBe(result.childPID) + expect(result.survived).toBe(false) + }, + 30_000, +) + +test.skipIf(process.platform === "win32" || !Bun.which("Rscript"))( + "built-in R release reaps a different-process-group descendant before killing the kernel leader", + async () => { + const result = await scenario("r") + expect(result.childAncestors).toContain(result.kernelPID) + expect(result.childPGID).toBe(result.childPID) + expect(result.survived).toBe(false) + }, + 30_000, +) diff --git a/backend/cli/test/science/kernel-provenance.test.ts b/backend/cli/test/science/kernel-provenance.test.ts index c6b54806..d1e63387 100644 --- a/backend/cli/test/science/kernel-provenance.test.ts +++ b/backend/cli/test/science/kernel-provenance.test.ts @@ -61,6 +61,15 @@ test("canonical runtime records agent kernel executions with outputs", async () status: "available", value: { language: "python", + environment_name: { status: "available", value: "python" }, + interpreter: { + status: "available", + value: { + name: "python", + binary: expect.any(String), + version: { status: "available", value: expect.stringMatching(/^Python /) }, + }, + }, incarnation: { status: "available", value: 1 }, process_id: { status: "available", value: expect.any(Number) }, process_started_at: { status: "available", value: expect.any(String) }, @@ -99,6 +108,12 @@ test("canonical runtime records agent kernel executions with outputs", async () messageID: "msg_kernel_origin", callID: "call_kernel_origin", kernelName: "agent", + kernelEnvironment: "python", + interpreter: { + name: "python", + binary: expect.any(String), + version: expect.stringMatching(/^Python /), + }, executionCount: 1, stdout: "", stderr: "", diff --git a/backend/cli/test/science/kernel-signal.test.ts b/backend/cli/test/science/kernel-signal.test.ts index 16685267..7ead7234 100644 --- a/backend/cli/test/science/kernel-signal.test.ts +++ b/backend/cli/test/science/kernel-signal.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test" import path from "node:path" import { pathToFileURL } from "node:url" +import { spawn } from "node:child_process" +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { KernelProcessIdentity } from "../../src/science/kernel/process" test("kernel cleanup handlers terminate the host process after SIGTERM", async () => { if (process.platform === "win32") return @@ -30,3 +33,17 @@ test("kernel cleanup handlers terminate the host process after SIGTERM", async ( } expect(code).toBe(143) }) + +test("persisted kernel identity reaps the exact orphan without trusting a reused PID", async () => { + if (process.platform === "win32") return + const child = spawn("sleep", ["30"], { detached: true, stdio: "ignore" }) + const identity = KernelProcessIdentity.capture(child) + expect(identity).toBeDefined() + expect(identity?.token).toHaveLength(64) + expect(identity?.token).toBe(await AuthorityProcessLedger.identity(child.pid!)) + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(true) + expect(await KernelProcessIdentity.terminate({ ...identity!, token: `${identity!.token}-wrong` })).toBe(false) + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(true) + expect(await KernelProcessIdentity.terminate(identity)).toBe(true) + expect(KernelProcessIdentity.matchesRecorded(identity)).toBe(false) +}) diff --git a/backend/cli/test/science/kernel/interpreter.test.ts b/backend/cli/test/science/kernel/interpreter.test.ts new file mode 100644 index 00000000..292c85ab --- /dev/null +++ b/backend/cli/test/science/kernel/interpreter.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { + KernelEnvironmentName, + KernelEnvironmentUnavailable, + pythonEnvironment, +} from "../../../src/science/kernel/interpreter" +import { tmpdir } from "../../fixture/fixture" +import { Instance } from "../../../src/project/instance" +import { ProjectTrust } from "../../../src/project/trust" +import { Session } from "../../../src/session" +import { NotebookTool } from "../../../src/tool/notebook" +import { ExecutionAuthority } from "../../../src/project/execution" +import { KernelRuntime, type KernelIdentity } from "../../../src/science/kernel/registry" +import { AuthorityProcessLedger } from "../../../src/project/authority-process" +import "../../../src/tool/rkernel" + +test("Python environment names cannot escape the project virtual-environment directory", () => { + expect(() => KernelEnvironmentName.parse("../nbody")).toThrow("path separators") + expect(() => KernelEnvironmentName.parse("nbody/main")).toThrow("path separators") + expect(KernelEnvironmentName.parse("nbody-3.12")).toBe("nbody-3.12") +}) + +test("the default Python environment falls back to the host but a missing named environment fails closed", async () => { + await using tmp = await tmpdir() + expect(await pythonEnvironment(tmp.path)).toEqual({ environmentName: "python" }) + await expect(pythonEnvironment(tmp.path, "nbody")).rejects.toBeInstanceOf(KernelEnvironmentUnavailable) +}) + +test("a named Python environment resolves only its fixed project-local interpreter path", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, ".venv", "nbody") + const bin = process.platform === "win32" ? path.join(root, "Scripts") : path.join(root, "bin") + const binary = path.join(bin, process.platform === "win32" ? "python.exe" : "python") + await fs.mkdir(bin, { recursive: true }) + await fs.writeFile(binary, process.platform === "win32" ? "test" : "#!/bin/sh\nexit 0\n") + if (process.platform !== "win32") await fs.chmod(binary, 0o755) + + const result = await pythonEnvironment(tmp.path, "nbody") + expect(result.binary).toBe(binary) + expect(result.environmentName).toBe("nbody") + expect(result.env?.VIRTUAL_ENV).toBe(root) + expect(result.env?.PATH?.split(path.delimiter)[0]).toBe(bin) +}) + +test("an untrusted project .venv interpreter cannot execute during discovery", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const root = path.join(dir, ".venv") + const bin = process.platform === "win32" ? path.join(root, "Scripts") : path.join(root, "bin") + const binary = path.join(bin, process.platform === "win32" ? "python.exe" : "python") + const marker = path.join(dir, "malicious-venv-executed") + await fs.mkdir(bin, { recursive: true }) + await fs.writeFile( + binary, + process.platform === "win32" + ? "malicious project executable" + : `#!/bin/sh\nprintf pwned > ${JSON.stringify(marker)}\nexit 0\n`, + ) + if (process.platform !== "win32") await fs.chmod(binary, 0o755) + return { marker } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const tool = await NotebookTool.init() + const run = tool.execute( + { code: "print('should not run')", timeout: 5_000 }, + { + sessionID: session.id, + messageID: "message_untrusted_venv", + callID: "call_untrusted_venv", + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, + }, + ) + + await expect(run).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + }, + }) +}) + +test.skipIf(process.platform === "win32")( + "an R override is discovered without execution and reports its version only after durable READY", + async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const marker = path.join(dir, "r-version-preflight-executed") + const binary = path.join(dir, "project-Rscript") + await fs.writeFile( + binary, + `#!/bin/sh +if [ "\${1-}" = "--version" ]; then + printf executed > ${JSON.stringify(marker)} + printf 'R version 0.0 preflight\\n' + exit 0 +fi +printf '__OPENSCIENCE_KERNEL_READY__R version 9.9.0 governed\\n' +while IFS= read -r line; do + if [ "$line" = "__OPENSCIENCE_CODE_END__" ]; then + printf '__OPENSCIENCE_R_RESULT_START__\\nOK:1\\nIMG:\\n__OPENSCIENCE_R_OUT__\\n42\\n__OPENSCIENCE_R_MSG__\\n\\n__OPENSCIENCE_R_END__\\n' + fi +done +`, + ) + await fs.chmod(binary, 0o755) + return { binary, marker } + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const identity: KernelIdentity = { + projectID: Instance.project.id, + sessionID: session.id, + name: "r-discovery-boundary", + language: "r", + } + await expect( + KernelRuntime.execute(identity, "1 + 1", undefined, { + binary: tmp.extra.binary, + environmentName: "project-r", + }), + ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + + const trust = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: trust.root }) + try { + const result = await KernelRuntime.execute(identity, "1 + 1", undefined, { + binary: tmp.extra.binary, + environmentName: "project-r", + }) + expect(result.ok).toBe(true) + expect(result.stdout.trim()).toBe("42") + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + expect(KernelRuntime.status(identity).environment?.interpreter).toMatchObject({ + name: "project-r", + binary: tmp.extra.binary, + version: "R version 9.9.0 governed", + }) + + const ledger = await Bun.file(AuthorityProcessLedger.pathForTests()).json() + expect( + (ledger as Array<{ kind?: string; project_id?: string; session_id?: string }>).some( + (entry) => + entry.kind === "kernel" && entry.project_id === Instance.project.id && entry.session_id === session.id, + ), + ).toBe(true) + } finally { + await KernelRuntime.release(identity) + } + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + }, + }) + }, + 30_000, +) diff --git a/backend/cli/test/server/file-artifact.test.ts b/backend/cli/test/server/file-artifact.test.ts index a506194f..4c2c6f43 100644 --- a/backend/cli/test/server/file-artifact.test.ts +++ b/backend/cli/test/server/file-artifact.test.ts @@ -6,6 +6,7 @@ import { ArtifactStore } from "../../src/artifact/store" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { FileRoutes } from "../../src/server/routes/file" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" interface Saved { @@ -237,6 +238,33 @@ describe("/file/artifact", () => { }) }) + test("rejects same-size blob corruption and repairs it from a known-good save", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const info = await Session.create({}) + sessions.add(info.id) + const content = "immutable research bytes" + await Bun.write(path.join(tmp.path, "integrity.txt"), content) + const saved = (await (await save({ path: "integrity.txt", sessionID: info.id })).json()) as Saved + const sha = saved.current.sha256 + const blob = path.join(Global.Path.data, "artifact-store", "blobs", sha.slice(0, 2), sha.slice(2, 4), sha) + await Bun.write(blob, "corrupted research bytes") + expect(Buffer.byteLength("corrupted research bytes")).toBe(Buffer.byteLength(content)) + expect(await ArtifactStore.read(Instance.project.id, saved.id)).toBeUndefined() + + const repaired = (await (await save({ path: "integrity.txt", sessionID: info.id })).json()) as Saved + expect(repaired.id).toBe(saved.id) + expect(repaired.current.version).toBe(2) + expect(await (await ArtifactStore.read(Instance.project.id, repaired.id))?.content.text()).toBe(content) + expect( + await (await ArtifactStore.read(Instance.project.id, repaired.id, saved.currentVersionID))?.content.text(), + ).toBe(content) + }, + }) + }) + test("renames, trashes, restores, and expires artifacts without changing immutable bytes", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/backend/cli/test/server/notebook.test.ts b/backend/cli/test/server/notebook.test.ts index ec75bc61..a99fa7d2 100644 --- a/backend/cli/test/server/notebook.test.ts +++ b/backend/cli/test/server/notebook.test.ts @@ -9,6 +9,22 @@ import { Server } from "../../src/server/server" import { KernelRuntime } from "../../src/science/kernel/registry" import { KernelMetrics } from "../../src/science/kernel/metrics" import { Sandbox } from "../../src/sandbox/sandbox" +import fs from "node:fs/promises" +import path from "node:path" + +async function createPythonEnvironment(root: string, name: string) { + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the notebook route tests") + const target = path.join(root, ".venv", name) + const proc = Bun.spawn([python, "-m", "venv", "--without-pip", target], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + if (code !== 0) throw new Error(`Could not create ${name} test environment: ${stderr}`) + return process.platform === "win32" ? path.join(target, "Scripts", "python.exe") : path.join(target, "bin", "python") +} const alive = (pid: number) => { try { @@ -439,15 +455,24 @@ describe("/notebook routes", () => { const first = execute( "(__import__('time').sleep(0.5), globals().__setitem__('queue_value', ['first']), 'first')[-1]", ) - const waitForKernel = async (attempt = 0): Promise => { - const response = await app.request( - `/status?sessionID=${encodeURIComponent(session.id)}&id=analysis.ipynb&language=python`, - ) - const status = (await response.json()) as { active?: boolean } - if (status.active) return - if (attempt >= 100) throw new Error("kernel did not start") - await Bun.sleep(10) - return waitForKernel(attempt + 1) + const waitForKernel = async (): Promise => { + // Kernel startup includes the governed-process handshake and has a + // bounded 15s production timeout. A fixed 101-poll budget made this + // test impose an unrelated ~1s timeout and fail under full-suite CPU + // pressure while the runtime was still correctly reporting + // `starting`. Keep the assertion bounded, but against the real + // startup contract and a monotonic deadline. + const deadline = performance.now() + 20_000 + let last: unknown + while (performance.now() < deadline) { + const response = await app.request( + `/status?sessionID=${encodeURIComponent(session.id)}&id=analysis.ipynb&language=python`, + ) + last = await response.json() + if ((last as { active?: boolean }).active) return + await Bun.sleep(10) + } + throw new Error(`kernel did not start; last status: ${JSON.stringify(last)}`) } await waitForKernel() const secondCode = "(__import__('time').sleep(0.4), queue_value.append('second'), queue_value)[-1]" @@ -507,7 +532,7 @@ describe("/notebook routes", () => { }) }, }) - }, 30_000) + }, 45_000) test("holds the queue slot of the booting cell before the kernel reports active", async () => { await using tmp = await tmpdir({ git: true }) @@ -1282,6 +1307,160 @@ describe("/notebook routes", () => { expect(response.status).toBe(400) }) + test("rejects an invalid interpreter environment instead of running the default interpreter", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const response = await NotebookRoutes().request("/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: session.id, + id: "analysis.ipynb", + language: "python", + environment: "../nbody", + code: "raise RuntimeError('must not execute')", + }), + }) + + expect(response.status).toBe(400) + const missing = await NotebookRoutes().request("/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: session.id, + id: "analysis.ipynb", + language: "python", + environment: "nbody", + code: "raise RuntimeError('must not execute')", + }), + }) + expect(missing.status).toBe(400) + expect(await missing.text()).toContain("Python environment 'nbody' was not found") + expect(KernelRuntime.list(session.id)).toEqual([]) + }, + }) + }) + + test("addresses separate persistent Python processes and site-packages by environment", async () => { + await using tmp = await tmpdir({ git: true }) + const python = await createPythonEnvironment(tmp.path, "python") + const nbody = await createPythonEnvironment(tmp.path, "nbody") + const marker = `openscience_env_marker_${crypto.randomUUID().replaceAll("-", "")}` + const site = Bun.spawnSync([nbody, "-c", "import site; print(site.getsitepackages()[0])"]) + expect(site.success).toBe(true) + await fs.writeFile(path.join(site.stdout.toString().trim(), `${marker}.py`), "VALUE = 99\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const execute = async (environment: string, code: string, id = "analysis.ipynb") => { + const response = await NotebookRoutes().request("/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: session.id, + id, + language: "python", + environment, + code, + }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { + ok: boolean + outputs: Array<{ output_type: string; name?: string; text?: string; data?: Record }> + } + } + const status = async (environment: string) => { + const query = new URLSearchParams({ + sessionID: session.id, + id: "analysis.ipynb", + language: "python", + environment, + }) + const response = await NotebookRoutes().request(`/status?${query}`) + expect(response.status).toBe(200) + return (await response.json()) as { + process_id: number + environment_name: string + environment: { interpreter: { name: string; binary: string; version?: string } } + } + } + + const [plain, isolated] = await Promise.all([ + execute( + "python", + `import importlib.util\nx = 41\nprint(importlib.util.find_spec(${JSON.stringify(marker)}) is None)`, + ), + execute("nbody", `import ${marker}\nx = ${marker}.VALUE\nprint(x)`), + ]) + expect(plain.ok).toBe(true) + expect(plain.outputs.some((output) => output.text?.trim() === "True")).toBe(true) + expect(isolated.ok).toBe(true) + expect(isolated.outputs.some((output) => output.text?.trim() === "99")).toBe(true) + + const [plainState, isolatedState, plainStatus, isolatedStatus] = await Promise.all([ + execute("python", "x + 1", "other.ipynb"), + execute("nbody", "x + 1", "other.ipynb"), + status("python"), + status("nbody"), + ]) + expect(plainState.outputs.some((output) => output.data?.["text/plain"] === "42")).toBe(true) + expect(isolatedState.outputs.some((output) => output.data?.["text/plain"] === "100")).toBe(true) + expect(plainStatus.process_id).not.toBe(isolatedStatus.process_id) + expect(plainStatus.environment_name).toBe("python") + expect(isolatedStatus.environment_name).toBe("nbody") + expect(plainStatus.environment.interpreter).toMatchObject({ name: "python", binary: python }) + expect(isolatedStatus.environment.interpreter).toMatchObject({ name: "nbody", binary: nbody }) + expect(plainStatus.environment.interpreter.version).toMatch(/^Python /) + expect(isolatedStatus.environment.interpreter.version).toMatch(/^Python /) + + const restarted = await NotebookRoutes().request("/restart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: session.id, + id: "analysis.ipynb", + language: "python", + environment: "nbody", + }), + }) + expect(restarted.status).toBe(200) + const fresh = (await restarted.json()) as { + process_id: number + incarnation: number + environment: { interpreter: { name: string; binary: string } } + } + expect(fresh.process_id).not.toBe(isolatedStatus.process_id) + expect(fresh.incarnation).toBe(2) + expect(fresh.environment.interpreter).toMatchObject({ name: "nbody", binary: nbody }) + const reset = await execute("nbody", '"x" in globals()', "after-restart.ipynb") + expect(reset.outputs.some((output) => output.data?.["text/plain"] === "False")).toBe(true) + + await Promise.all( + ["python", "nbody"].map((environment) => + NotebookRoutes().request("/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: session.id, + id: "analysis.ipynb", + language: "python", + environment, + }), + }), + ), + ) + }, + }) + }, 120_000) + test("rejects kernel operations for a session outside the active project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/backend/cli/test/server/project-selection-routes.test.ts b/backend/cli/test/server/project-selection-routes.test.ts index 09c03a57..092423c4 100644 --- a/backend/cli/test/server/project-selection-routes.test.ts +++ b/backend/cli/test/server/project-selection-routes.test.ts @@ -1,22 +1,41 @@ import { $ } from "bun" -import { describe, expect, test } from "bun:test" +import { describe, expect, setDefaultTimeout, test } from "bun:test" import fs from "fs/promises" import os from "os" import path from "path" import { Project } from "../../src/project/project" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" import { Server } from "../../src/server/server" import { Storage } from "../../src/storage/storage" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +// These integration cases cross the durable authority/trust boundary and run +// real repository probes. They complete in roughly 10–12 seconds in +// isolation, but can legitimately queue behind other native lifecycle tests +// when Bun executes the full backend suite concurrently. +setDefaultTimeout(30_000) const fetch = Server.internalFetch() +async function trust(directory: string) { + return Instance.provide({ + directory, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + return ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) +} + describe("pre-instance project selection routes", () => { test("uses an opaque selector without a caller-owned directory", async () => { await using tmp = await tmpdir({ git: true }) const created = await Project.fromDirectory(tmp.path) + await trust(tmp.path) await fs.mkdir(path.join(tmp.path, ".openscience"), { recursive: true }) await Bun.write(path.join(tmp.path, ".openscience", "project.json"), JSON.stringify({ project_id: "atlas-root" })) @@ -148,6 +167,7 @@ describe("pre-instance project selection routes", () => { test("canonicalizes a symlink override before repository execution", async () => { await using tmp = await tmpdir({ git: true }) const created = await Project.fromDirectory(tmp.path) + await trust(tmp.path) const link = path.join(path.dirname(tmp.path), `${path.basename(tmp.path)}-route-alias`) await fs.symlink(tmp.path, link) @@ -166,7 +186,7 @@ describe("pre-instance project selection routes", () => { await fs.rm(link, { force: true }) }) - test("preserves raw-directory clients and encoded deep links without a selector", async () => { + test("keeps folder discovery compatible but requires a project capability for repository execution", async () => { await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), `${path.basename(tmp.path)}-legacy-alias`) await fs.symlink(tmp.path, link) @@ -180,11 +200,8 @@ describe("pre-instance project selection routes", () => { body: JSON.stringify({ path: link }), }) - expect(repo.status).toBe(200) - expect(await repo.json()).toMatchObject({ - directory: tmp.path, - isGit: true, - }) + expect(repo.status).toBe(400) + expect(await repo.json()).toEqual({ error: "Repository operations require an opaque project selector" }) expect(folder.status).toBe(200) expect(await folder.json()).toMatchObject({ ok: true, @@ -207,6 +224,7 @@ describe("pre-instance project selection routes", () => { }, }) const created = await Project.fromDirectory(tmp.path) + await trust(tmp.path) const response = await fetch("http://openscience.internal/api/repo/status", { headers: { @@ -265,6 +283,7 @@ describe("pre-instance project selection routes", () => { test("accepts body project selection for repository mutations", async () => { await using tmp = await tmpdir({ git: true }) const created = await Project.fromDirectory(tmp.path) + await trust(tmp.path) await Bun.write(path.join(tmp.path, "result.txt"), "done\n") const response = await fetch("http://openscience.internal/api/repo/commit", { @@ -282,4 +301,40 @@ describe("pre-instance project selection routes", () => { expect(await response.json()).toMatchObject({ committed: true }) expect((await $`git log -1 --format=%s`.cwd(tmp.path).quiet().text()).trim()).toBe("record result") }) + + test("denies repository hooks before trust and confines them after trust", async () => { + await using tmp = await tmpdir({ git: true }) + const created = await Project.fromDirectory(tmp.path) + const inside = path.join(tmp.path, "hook-ran") + const outside = path.join(path.dirname(tmp.path), `openscience-repo-hook-${crypto.randomUUID()}`) + const hook = path.join(tmp.path, ".git", "hooks", "pre-commit") + await Bun.write( + hook, + `#!/bin/sh +printf ran > ${JSON.stringify(inside)} +printf escaped > ${JSON.stringify(outside)} 2>/dev/null || true +`, + ) + await fs.chmod(hook, 0o700) + await Bun.write(path.join(tmp.path, "result.txt"), "done\n") + + const request = () => + fetch("http://openscience.internal/api/repo/commit", { + method: "POST", + headers: { "content-type": "application/json", "x-openscience-project": created.project.id }, + body: JSON.stringify({ message: "record result" }), + }) + + const denied = await request() + expect(denied.status).toBe(400) + expect(await Bun.file(inside).exists()).toBe(false) + expect(await Bun.file(outside).exists()).toBe(false) + + await trust(tmp.path) + const committed = await request() + expect(committed.status).toBe(200) + expect(await Bun.file(inside).text()).toBe("ran") + if (Sandbox.describe().available) expect(await Bun.file(outside).exists()).toBe(false) + await fs.rm(outside, { force: true }) + }) }) diff --git a/backend/cli/test/server/session-shell-security.test.ts b/backend/cli/test/server/session-shell-security.test.ts new file mode 100644 index 00000000..88b26b9b --- /dev/null +++ b/backend/cli/test/server/session-shell-security.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { tmpdir } from "../fixture/fixture" + +test("the legacy session shell route requires trust and keeps writes inside its sandbox", async () => { + await using workspace = await tmpdir() + await using outside = await tmpdir() + const state = await Instance.provide({ + directory: workspace.path, + fn: async () => ({ projectID: Instance.project.id, session: await Session.create({ title: "shell route" }) }), + }) + const target = path.join(outside.path, "escaped") + const fetch = Server.internalFetch() + const invoke = () => + fetch( + `http://openscience.internal/session/${state.session.id}/shell?directory=${encodeURIComponent(workspace.path)}`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-openscience-project": state.projectID, + }, + body: JSON.stringify({ + agent: "research", + model: { providerID: "test", modelID: "test" }, + command: `printf escaped > ${JSON.stringify(target)}`, + }), + }, + ) + + const denied = await invoke() + expect(denied.status).toBe(403) + expect(await denied.json()).toMatchObject({ name: "ExecutionAuthorityDeniedError" }) + expect(await Bun.file(target).exists()).toBe(false) + + await Instance.provide({ + directory: workspace.path, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) + const confined = await invoke() + expect(confined.status).toBe(200) + expect(await Bun.file(target).exists()).toBe(false) + + await Instance.provide({ + directory: workspace.path, + fn: () => Session.remove(state.session.id), + }) +}, 30_000) diff --git a/backend/cli/test/server/settings-compute.test.ts b/backend/cli/test/server/settings-compute.test.ts index 6e561b90..794ec9c4 100644 --- a/backend/cli/test/server/settings-compute.test.ts +++ b/backend/cli/test/server/settings-compute.test.ts @@ -17,6 +17,10 @@ Log.init({ print: false }) const fetch = Server.internalFetch() const jobs = "http://openscience.internal/settings/compute/jobs" +// These cases exercise real OS-owned process trees. On macOS the durable +// responsibility handoff and verified descendant reap routinely exceed Bun's +// 5s unit-test default even though the payload itself exits immediately. +const nativeLifecycleTimeout = 30_000 // Every env var the compute store can own — cleaned up so other test files // never see leftovers from this one. @@ -275,47 +279,51 @@ test("preserves a provider variable replaced while a project instance is active" delete process.env["VAST_API_KEY"] }) -test("compute job routes execute a real local command and expose its log", async () => { - await using tmp = await tmpdir() - const current = await session(tmp.path) - const query = `?directory=${encodeURIComponent(tmp.path)}` - const started = await ComputeSettingsRoutes().request(`/jobs${query}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - sessionID: current.id, - name: "route smoke test", - command: "printf 'compute-route-ok\\n'", - target: { kind: "local" }, - }), - }) - expect(started.status).toBe(200) - const first = (await started.json()) as { id: string } - const final = await (async () => { - for (const _ of Array.from({ length: 100 })) { - const response = await ComputeSettingsRoutes().request(`/jobs${query}`) - const jobs = (await response.json()) as { id: string; status: string }[] - const job = jobs.find((item) => item.id === first.id) - if (job && ["succeeded", "failed", "cancelled"].includes(job.status)) return job - await Bun.sleep(20) - } - throw new Error("Timed out waiting for route compute job") - })() - expect(final.status).toBe("succeeded") +test( + "compute job routes execute a real local command and expose its log", + async () => { + await using tmp = await tmpdir() + const current = await session(tmp.path) + const query = `?directory=${encodeURIComponent(tmp.path)}` + const started = await ComputeSettingsRoutes().request(`/jobs${query}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionID: current.id, + name: "route smoke test", + command: "printf 'compute-route-ok\\n'", + target: { kind: "local" }, + }), + }) + expect(started.status).toBe(200) + const first = (await started.json()) as { id: string } + const final = await (async () => { + for (const _ of Array.from({ length: 100 })) { + const response = await ComputeSettingsRoutes().request(`/jobs${query}`) + const jobs = (await response.json()) as { id: string; status: string }[] + const job = jobs.find((item) => item.id === first.id) + if (job && ["succeeded", "failed", "cancelled"].includes(job.status)) return job + await Bun.sleep(20) + } + throw new Error("Timed out waiting for route compute job") + })() + expect(final.status).toBe("succeeded") - const output = await ComputeSettingsRoutes().request(`/jobs/${first.id}/log${query}`) - expect(output.status).toBe(200) - expect(await output.json()).toEqual({ log: "compute-route-ok\n" }) + const output = await ComputeSettingsRoutes().request(`/jobs/${first.id}/log${query}`) + expect(output.status).toBe(200) + expect(await output.json()).toEqual({ log: "compute-route-ok\n" }) - const events = await ComputeSettingsRoutes().request(`/jobs/${first.id}/events${query}`) - expect(events.status).toBe(200) - expect(await events.json()).toEqual({ events: "" }) + const events = await ComputeSettingsRoutes().request(`/jobs/${first.id}/events${query}`) + expect(events.status).toBe(200) + expect(await events.json()).toEqual({ events: "" }) - const cleared = await ComputeSettingsRoutes().request(`/jobs/completed${query}`, { method: "DELETE" }) - expect(cleared.status).toBe(200) -}) + const cleared = await ComputeSettingsRoutes().request(`/jobs/completed${query}`, { method: "DELETE" }) + expect(cleared.status).toBe(200) + }, + nativeLifecycleTimeout, +) -test("compute job routes fail closed while remote lifecycle support is incomplete", async () => { +test("compute job routes reject an unknown SSH profile before dispatch", async () => { await using tmp = await tmpdir() const current = await session(tmp.path) const response = await ComputeSettingsRoutes().request(`/jobs?directory=${encodeURIComponent(tmp.path)}`, { @@ -328,8 +336,8 @@ test("compute job routes fail closed while remote lifecycle support is incomplet target: { kind: "ssh", host_id: "does-not-exist" }, }), }) - expect(response.status).toBe(409) - expect(await response.json()).toMatchObject({ error: "remote_compute_unavailable" }) + expect(response.status).toBe(400) + expect(await response.text()).toContain("The selected SSH compute profile was not found") }) test("compute job routes require a valid project directory", async () => { @@ -342,73 +350,83 @@ test("compute job routes require a valid project directory", async () => { expect(invalid.status).toBe(400) }) -test("compute job routes isolate list, log, cancel, and clear by project", async () => { - await using first = await tmpdir() - await using second = await tmpdir() - const current = await session(first.path) - const one = `?directory=${encodeURIComponent(first.path)}` - const two = `?directory=${encodeURIComponent(second.path)}` - const started = await ComputeSettingsRoutes().request(`/jobs${one}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - sessionID: current.id, - name: "project isolation", - command: "sleep 30", - target: { kind: "local" }, - }), - }) - expect(started.status).toBe(200) - const job = (await started.json()) as { id: string } - - expect(await (await ComputeSettingsRoutes().request(`/jobs${two}`)).json()).toEqual([]) - expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/log${two}`)).status).toBe(404) - expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/cancel${two}`, { method: "POST" })).status).toBe(404) - expect(await (await ComputeSettingsRoutes().request(`/jobs/completed${two}`, { method: "DELETE" })).json()).toEqual({ - cleared: 0, - }) - - expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/cancel${one}`, { method: "POST" })).status).toBe(200) - expect((await ComputeSettingsRoutes().request(`/jobs/completed${one}`, { method: "DELETE" })).status).toBe(200) -}) - -test("mounted compute routes use an opaque project selector for every job operation", async () => { - await using tmp = await tmpdir() - const created = await Project.fromDirectory(tmp.path) - const current = await session(tmp.path) - const headers = { - "content-type": "application/json", - "x-openscience-project": created.project.id, - } - const started = await fetch(jobs, { - method: "POST", - headers, - body: JSON.stringify({ - sessionID: current.id, - name: "project capability", - command: "printf 'project-capability-ok\\n'", - target: { kind: "local" }, - }), - }) - - expect(started.status).toBe(200) - const first = (await started.json()) as { - id: string - cwd: string - scope: { directory: string } - } - expect(first.cwd).toBe(tmp.path) - expect(first.scope.directory).toBe(tmp.path) - expect((await settle(jobs, first.id, headers)).status).toBe("succeeded") +test( + "compute job routes isolate list, log, cancel, and clear by project", + async () => { + await using first = await tmpdir() + await using second = await tmpdir() + const current = await session(first.path) + const one = `?directory=${encodeURIComponent(first.path)}` + const two = `?directory=${encodeURIComponent(second.path)}` + const started = await ComputeSettingsRoutes().request(`/jobs${one}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionID: current.id, + name: "project isolation", + command: "sleep 30", + target: { kind: "local" }, + }), + }) + expect(started.status).toBe(200) + const job = (await started.json()) as { id: string } + + expect(await (await ComputeSettingsRoutes().request(`/jobs${two}`)).json()).toEqual([]) + expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/log${two}`)).status).toBe(404) + expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/cancel${two}`, { method: "POST" })).status).toBe(404) + expect(await (await ComputeSettingsRoutes().request(`/jobs/completed${two}`, { method: "DELETE" })).json()).toEqual( + { + cleared: 0, + }, + ) + + expect((await ComputeSettingsRoutes().request(`/jobs/${job.id}/cancel${one}`, { method: "POST" })).status).toBe(200) + expect((await ComputeSettingsRoutes().request(`/jobs/completed${one}`, { method: "DELETE" })).status).toBe(200) + }, + nativeLifecycleTimeout, +) + +test( + "mounted compute routes use an opaque project selector for every job operation", + async () => { + await using tmp = await tmpdir() + const created = await Project.fromDirectory(tmp.path) + const current = await session(tmp.path) + const headers = { + "content-type": "application/json", + "x-openscience-project": created.project.id, + } + const started = await fetch(jobs, { + method: "POST", + headers, + body: JSON.stringify({ + sessionID: current.id, + name: "project capability", + command: "printf 'project-capability-ok\\n'", + target: { kind: "local" }, + }), + }) + + expect(started.status).toBe(200) + const first = (await started.json()) as { + id: string + cwd: string + scope: { directory: string } + } + expect(first.cwd).toBe(tmp.path) + expect(first.scope.directory).toBe(tmp.path) + expect((await settle(jobs, first.id, headers)).status).toBe("succeeded") - const output = await fetch(`${jobs}/${first.id}/log`, { headers }) - expect(output.status).toBe(200) - expect(await output.json()).toEqual({ log: "project-capability-ok\n" }) + const output = await fetch(`${jobs}/${first.id}/log`, { headers }) + expect(output.status).toBe(200) + expect(await output.json()).toEqual({ log: "project-capability-ok\n" }) - const cleared = await fetch(`${jobs}/completed`, { method: "DELETE", headers }) - expect(cleared.status).toBe(200) - expect(await cleared.json()).toEqual({ cleared: 1 }) -}) + const cleared = await fetch(`${jobs}/completed`, { method: "DELETE", headers }) + expect(cleared.status).toBe(200) + expect(await cleared.json()).toEqual({ cleared: 1 }) + }, + nativeLifecycleTimeout, +) test("mounted compute routes reject unknown, stale, and mismatched project selectors", async () => { await using current = await tmpdir() @@ -463,90 +481,98 @@ test("mounted compute routes reject unknown, stale, and mismatched project selec }) }) -test("mounted compute routes never resolve another project's job id", async () => { - await using first = await tmpdir() - await using second = await tmpdir() - const one = await Project.fromDirectory(first.path) - const two = await Project.fromDirectory(second.path) - const current = await session(first.path) - const firstHeaders = { - "content-type": "application/json", - "x-openscience-project": one.project.id, - } - const secondHeaders = { - "content-type": "application/json", - "x-openscience-project": two.project.id, - } - const started = await fetch(jobs, { - method: "POST", - headers: firstHeaders, - body: JSON.stringify({ - sessionID: current.id, - name: "cross-project isolation", - command: "printf 'cross-project-ok\\n'", - target: { kind: "local" }, - }), - }) - expect(started.status).toBe(200) - const job = (await started.json()) as { id: string } - expect((await settle(jobs, job.id, firstHeaders)).status).toBe("succeeded") - - const [listed, output, cancelled, cleared] = await Promise.all([ - fetch(jobs, { headers: secondHeaders }), - fetch(`${jobs}/${job.id}/log`, { headers: secondHeaders }), - fetch(`${jobs}/${job.id}/cancel`, { method: "POST", headers: secondHeaders }), - fetch(`${jobs}/completed`, { method: "DELETE", headers: secondHeaders }), - ]) - expect(await listed.json()).toEqual([]) - expect(output.status).toBe(404) - expect(cancelled.status).toBe(404) - expect(await cleared.json()).toEqual({ cleared: 0 }) - - expect(await (await fetch(`${jobs}/completed`, { method: "DELETE", headers: firstHeaders })).json()).toEqual({ - cleared: 1, - }) -}) - -test("legacy directory requests and project selectors share one canonical symlink scope", async () => { - await using tmp = await tmpdir() - const created = await Project.fromDirectory(tmp.path) - const current = await session(tmp.path) - const link = path.join(path.dirname(tmp.path), `${path.basename(tmp.path)}-compute-alias`) - await fs.symlink(tmp.path, link) - const legacy = `${jobs}?directory=${encodeURIComponent(link)}` - const started = await fetch(legacy, { - method: "POST", - headers: { +test( + "mounted compute routes never resolve another project's job id", + async () => { + await using first = await tmpdir() + await using second = await tmpdir() + const one = await Project.fromDirectory(first.path) + const two = await Project.fromDirectory(second.path) + const current = await session(first.path) + const firstHeaders = { "content-type": "application/json", - }, - body: JSON.stringify({ - sessionID: current.id, - name: "legacy symlink", - command: "printf 'legacy-symlink-ok\\n'", - target: { kind: "local" }, - }), - }) + "x-openscience-project": one.project.id, + } + const secondHeaders = { + "content-type": "application/json", + "x-openscience-project": two.project.id, + } + const started = await fetch(jobs, { + method: "POST", + headers: firstHeaders, + body: JSON.stringify({ + sessionID: current.id, + name: "cross-project isolation", + command: "printf 'cross-project-ok\\n'", + target: { kind: "local" }, + }), + }) + expect(started.status).toBe(200) + const job = (await started.json()) as { id: string } + expect((await settle(jobs, job.id, firstHeaders)).status).toBe("succeeded") + + const [listed, output, cancelled, cleared] = await Promise.all([ + fetch(jobs, { headers: secondHeaders }), + fetch(`${jobs}/${job.id}/log`, { headers: secondHeaders }), + fetch(`${jobs}/${job.id}/cancel`, { method: "POST", headers: secondHeaders }), + fetch(`${jobs}/completed`, { method: "DELETE", headers: secondHeaders }), + ]) + expect(await listed.json()).toEqual([]) + expect(output.status).toBe(404) + expect(cancelled.status).toBe(404) + expect(await cleared.json()).toEqual({ cleared: 0 }) + + expect(await (await fetch(`${jobs}/completed`, { method: "DELETE", headers: firstHeaders })).json()).toEqual({ + cleared: 1, + }) + }, + nativeLifecycleTimeout, +) + +test( + "legacy directory requests and project selectors share one canonical symlink scope", + async () => { + await using tmp = await tmpdir() + const created = await Project.fromDirectory(tmp.path) + const current = await session(tmp.path) + const link = path.join(path.dirname(tmp.path), `${path.basename(tmp.path)}-compute-alias`) + await fs.symlink(tmp.path, link) + const legacy = `${jobs}?directory=${encodeURIComponent(link)}` + const started = await fetch(legacy, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + sessionID: current.id, + name: "legacy symlink", + command: "printf 'legacy-symlink-ok\\n'", + target: { kind: "local" }, + }), + }) + + expect(started.status).toBe(200) + const job = (await started.json()) as { + id: string + cwd: string + scope: { directory: string } + } + expect(job.cwd).toBe(tmp.path) + expect(job.scope.directory).toBe(tmp.path) - expect(started.status).toBe(200) - const job = (await started.json()) as { - id: string - cwd: string - scope: { directory: string } - } - expect(job.cwd).toBe(tmp.path) - expect(job.scope.directory).toBe(tmp.path) + const headers = { + "x-openscience-project": created.project.id, + } + expect((await settle(jobs, job.id, headers)).status).toBe("succeeded") + const output = await fetch(`${jobs}/${job.id}/log?directory=${encodeURIComponent(tmp.path)}`) + expect(output.status).toBe(200) + expect(await output.json()).toEqual({ log: "legacy-symlink-ok\n" }) + expect(await (await fetch(`${jobs}/completed`, { method: "DELETE", headers })).json()).toEqual({ cleared: 1 }) - const headers = { - "x-openscience-project": created.project.id, - } - expect((await settle(jobs, job.id, headers)).status).toBe("succeeded") - const output = await fetch(`${jobs}/${job.id}/log?directory=${encodeURIComponent(tmp.path)}`) - expect(output.status).toBe(200) - expect(await output.json()).toEqual({ log: "legacy-symlink-ok\n" }) - expect(await (await fetch(`${jobs}/completed`, { method: "DELETE", headers })).json()).toEqual({ cleared: 1 }) - - await fs.rm(link, { force: true }) -}) + await fs.rm(link, { force: true }) + }, + nativeLifecycleTimeout, +) test("read-only projects cannot start compute jobs or create side effects", async () => { await using tmp = await tmpdir() @@ -583,34 +609,38 @@ test("read-only projects cannot start compute jobs or create side effects", asyn expect(await (await fetch(jobs, { headers })).json()).toEqual([]) }) -test("revoking project trust cancels its running compute jobs", async () => { - if (!Sandbox.available()) return - await using tmp = await tmpdir() - const created = await Project.fromDirectory(tmp.path) - const current = await session(tmp.path) - const headers = { - "content-type": "application/json", - "x-openscience-project": created.project.id, - } - const started = await fetch(jobs, { - method: "POST", - headers, - body: JSON.stringify({ - sessionID: current.id, - name: "trust-bound job", - command: "sleep 30", - target: { kind: "local" }, - }), - }) - expect(started.status).toBe(200) - const job = (await started.json()) as { id: string } - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await ProjectTrust.update(Instance.project, { trusted: false }) - }, - }) +test( + "revoking project trust cancels its running compute jobs", + async () => { + if (!Sandbox.available()) return + await using tmp = await tmpdir() + const created = await Project.fromDirectory(tmp.path) + const current = await session(tmp.path) + const headers = { + "content-type": "application/json", + "x-openscience-project": created.project.id, + } + const started = await fetch(jobs, { + method: "POST", + headers, + body: JSON.stringify({ + sessionID: current.id, + name: "trust-bound job", + command: "sleep 30", + target: { kind: "local" }, + }), + }) + expect(started.status).toBe(200) + const job = (await started.json()) as { id: string } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + }, + }) - expect((await settle(jobs, job.id, headers)).status).toBe("cancelled") -}) + expect((await settle(jobs, job.id, headers)).status).toBe("cancelled") + }, + nativeLifecycleTimeout, +) diff --git a/backend/cli/test/server/settings-credentials.test.ts b/backend/cli/test/server/settings-credentials.test.ts index 057e9893..6e7e525f 100644 --- a/backend/cli/test/server/settings-credentials.test.ts +++ b/backend/cli/test/server/settings-credentials.test.ts @@ -96,13 +96,27 @@ test("credential catalog is categorized and injects integration and compute envi 'if (process.env.HUGGING_FACE_HUB_TOKEN !== "hf_catalog_test") throw new Error("Hugging Face alias was not injected")', 'if (process.env.AWS_ACCESS_KEY_ID !== "AKIATEST") throw new Error("AWS access key was not injected")', 'if (process.env.AWS_REGION !== "us-west-2") throw new Error("AWS region was not injected")', + 'const invalidField = await app.request("/custom:lab", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fields: { "api key": "secret" } }) })', + 'if (invalidField.status !== 400) throw new Error("invalid custom environment field was accepted")', + 'const unknown = await app.request("/not-a-service", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fields: { api_key: "secret" } }) })', + 'if (unknown.status !== 400) throw new Error("unknown credential service was accepted")', + 'const custom = await app.request("/custom:lab", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ label: "Lab", fields: { access_token: "lab-secret" } }) })', + "const customText = await custom.text()", + 'if (!custom.ok || customText.includes("lab-secret")) throw new Error("custom credential save leaked or failed")', + 'if (process.env.LAB_ACCESS_TOKEN !== "lab-secret") throw new Error("custom credential was not applied")', + 'const removed = await app.request("/custom:lab", { method: "DELETE" })', + 'if (!removed.ok || process.env.LAB_ACCESS_TOKEN !== undefined) throw new Error("custom credential was not removed live")', ].join("\n"), ) try { + const childEnv = { ...process.env } + delete childEnv.GOOGLE_APPLICATION_CREDENTIALS + delete childEnv.GOOGLE_APPLICATION_CREDENTIALS_JSON + delete childEnv.GOOGLE_CLOUD_PROJECT const proc = Bun.spawn([process.execPath, runner], { env: { - ...process.env, + ...childEnv, OPENSCIENCE_DATA_DIR: root, OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), OPENSCIENCE_TEST_HOME: path.join(root, "home"), @@ -119,3 +133,142 @@ test("credential catalog is categorized and injects integration and compute envi await fs.rm(root, { recursive: true, force: true }) } }) + +test("GCP plaintext is atomic, sandbox-masked, and removed for corrupt or deleted ciphertext", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-gcp-credential-")) + const runner = path.join(root, "gcp.ts") + const routes = new URL("../../src/server/routes/settings/credentials.ts", import.meta.url).href + const openscience = new URL("../../src/openscience/index.ts", import.meta.url).href + await Bun.write( + runner, + [ + `import fs from "node:fs/promises"`, + `import path from "node:path"`, + `import { CredentialsRoutes, applyCredentialEnv } from ${JSON.stringify(routes)}`, + `import { OpenScience } from ${JSON.stringify(openscience)}`, + `const root = process.env.OPENSCIENCE_DATA_DIR`, + `const plaintext = path.join(await fs.realpath(root), "gcp-service-account.json")`, + `const app = CredentialsRoutes()`, + `const invalid = await app.request("/gcp", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fields: { service_account_json: "not-json" } }) })`, + `if (invalid.status !== 400) throw new Error("invalid GCP JSON was accepted")`, + `const first = JSON.stringify({ type: "service_account", project_id: "one", private_key: "secret-one" })`, + `const saved = await app.request("/gcp", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fields: { project_id: "one", service_account_json: first } }) })`, + `if (!saved.ok || process.env.GOOGLE_APPLICATION_CREDENTIALS !== plaintext) throw new Error("GCP credential was not applied")`, + `if (await fs.readFile(plaintext, "utf8") !== first) throw new Error("GCP plaintext mismatch")`, + `if (process.platform !== "win32" && ((await fs.stat(plaintext)).mode & 0o777) !== 0o600) throw new Error("GCP plaintext permissions are not 0600")`, + `if (!OpenScience.kernelSensitivePaths().includes(plaintext)) throw new Error("GCP plaintext is not sandbox masked")`, + `const storePath = path.join(root, "credentials.json")`, + `const store = JSON.parse(await fs.readFile(storePath, "utf8"))`, + `store.gcp.fields.service_account_json = "invalid-ciphertext"`, + `await fs.writeFile(storePath, JSON.stringify(store))`, + `await applyCredentialEnv()`, + `if (await Bun.file(plaintext).exists()) throw new Error("corrupt ciphertext left GCP plaintext behind")`, + `if (process.env.GOOGLE_APPLICATION_CREDENTIALS !== undefined) throw new Error("corrupt ciphertext stayed in process.env")`, + `const listed = await app.request("/")`, + `const gcp = (await listed.json()).services.find((service) => service.id === "gcp")`, + `if (gcp.connected || gcp.set_fields.includes("service_account_json") || !gcp.set_fields.includes("project_id")) throw new Error("corrupt ciphertext was reported as connected")`, + ].join("\n"), + ) + + try { + const childEnv = { ...process.env } + delete childEnv.GOOGLE_APPLICATION_CREDENTIALS + delete childEnv.GOOGLE_APPLICATION_CREDENTIALS_JSON + delete childEnv.GOOGLE_CLOUD_PROJECT + const proc = Bun.spawn([process.execPath, runner], { + env: { + ...childEnv, + OPENSCIENCE_DATA_DIR: root, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + }, + stdout: "pipe", + stderr: "pipe", + }) + const [exit, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (exit !== 0) throw new Error(`GCP credential child exited ${exit}: ${error || "no stderr"}`) + expect(exit).toBe(0) + expect(error).not.toContain("Error") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("a second server drops rotated env and revokes an inherited child before its next spawn", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-credential-revision-")) + const mutate = path.join(root, "mutate.ts") + const worker = path.join(root, "worker.ts") + const ready = path.join(root, "ready") + const routes = new URL("../../src/server/routes/settings/credentials.ts", import.meta.url).href + const lifecycle = new URL("../../src/credentials/lifecycle.ts", import.meta.url).href + const openscience = new URL("../../src/openscience/index.ts", import.meta.url).href + await Bun.write( + mutate, + [ + `import { CredentialsRoutes } from ${JSON.stringify(routes)}`, + `const app = CredentialsRoutes()`, + `const remove = process.argv[2] === "remove"`, + `const response = await app.request("/custom:lab", remove ? { method: "DELETE" } : { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fields: { access_token: "cross-process-secret" } }) })`, + `if (!response.ok) throw new Error(await response.text())`, + ].join("\n"), + ) + await Bun.write( + worker, + [ + `import fs from "node:fs/promises"`, + `import { spawn } from "node:child_process"`, + `import { applyCredentialEnv } from ${JSON.stringify(routes)}`, + `import { CredentialLifecycle } from ${JSON.stringify(lifecycle)}`, + `import { OpenScience } from ${JSON.stringify(openscience)}`, + `await CredentialLifecycle.ensureFresh()`, + `await applyCredentialEnv()`, + `if (process.env.LAB_ACCESS_TOKEN !== "cross-process-secret") throw new Error("worker did not load initial secret")`, + `const inherited = await OpenScience.subprocessEnv(process.env)`, + `const child = spawn(process.execPath, ["-e", "console.log(process.env.LAB_ACCESS_TOKEN || 'absent'); setInterval(() => {}, 1000)"], { env: inherited, stdio: ["ignore", "pipe", "pipe"] })`, + `const first = await new Promise((resolve, reject) => { child.stdout.once("data", (data) => resolve(String(data).trim())); child.once("error", reject) })`, + `if (first !== "cross-process-secret") throw new Error("real child did not inherit initial secret")`, + `let revoked = false`, + `CredentialLifecycle.onRevoke(async () => { revoked = true; child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)) })`, + `CredentialLifecycle.watch(25)`, + `await fs.writeFile(${JSON.stringify(ready)}, "ready")`, + `for (let i = 0; i < 400 && !revoked; i++) await Bun.sleep(10)`, + `await CredentialLifecycle.ensureFresh()`, + `if (!revoked || (child.exitCode === null && child.signalCode === null)) throw new Error("inherited child was not revoked")`, + `if (process.env.LAB_ACCESS_TOKEN !== undefined) throw new Error("removed secret stayed in worker process.env")`, + `const next = Bun.spawn([process.execPath, "-e", "console.log(process.env.LAB_ACCESS_TOKEN || 'absent')"], { env: await OpenScience.subprocessEnv(process.env), stdout: "pipe", stderr: "pipe" })`, + `const [code, output] = await Promise.all([next.exited, new Response(next.stdout).text()])`, + `if (code !== 0 || output.trim() !== "absent") throw new Error("new child received the removed secret")`, + `CredentialLifecycle.stopWatching()`, + ].join("\n"), + ) + + const env = { + ...process.env, + OPENSCIENCE_DATA_DIR: root, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + } + const run = async (argv: string[]) => { + const proc = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }) + const [exit, error] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (exit !== 0) throw new Error(error) + } + + try { + await run([process.execPath, mutate, "set"]) + const live = Bun.spawn([process.execPath, worker], { env, stdout: "pipe", stderr: "pipe" }) + for (let i = 0; i < 400 && !(await Bun.file(ready).exists()); i++) await Bun.sleep(10) + expect(await Bun.file(ready).exists()).toBe(true) + await run([process.execPath, mutate, "remove"]) + const [exit, error] = await Promise.all([live.exited, new Response(live.stderr).text()]) + if (exit !== 0) throw new Error(error) + expect(exit).toBe(0) + expect(error).not.toContain("Error") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/server/settings-local.test.ts b/backend/cli/test/server/settings-local.test.ts index 2fe90d88..cf68835f 100644 --- a/backend/cli/test/server/settings-local.test.ts +++ b/backend/cli/test/server/settings-local.test.ts @@ -1,5 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" -import { LocalModelsRoutes } from "../../src/server/routes/settings/local" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { LocalModelsRoutes, LocalRuntime } from "../../src/server/routes/settings/local" const app = LocalModelsRoutes() @@ -22,6 +25,95 @@ beforeAll(() => { afterAll(() => server?.stop(true)) describe("/settings/local routes", () => { + test("local runtimes receive configuration without host credentials or control-plane state", () => { + const env = LocalRuntime.environment({ + PATH: "/usr/bin:/bin", + HOME: "/home/researcher", + OLLAMA_MODELS: "/models", + OPENAI_API_KEY: "provider-secret", + AWS_SECRET_ACCESS_KEY: "cloud-secret", + MODAL_TOKEN_SECRET: "modal-secret", + ATLAS_API_KEY: "atlas-secret", + OPENSCIENCE_CONFIG_CONTENT: "control-plane-state", + DYLD_INSERT_LIBRARIES: "/tmp/inject.dylib", + PYTHONSTARTUP: "/tmp/startup.py", + }) + + expect(env.PATH).toBe("/usr/bin:/bin") + expect(env.HOME).toBe("/home/researcher") + expect(env.OLLAMA_MODELS).toBe("/models") + expect(env.OPENAI_API_KEY).toBeUndefined() + expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(env.MODAL_TOKEN_SECRET).toBeUndefined() + expect(env.ATLAS_API_KEY).toBeUndefined() + expect(env.OPENSCIENCE_CONFIG_CONTENT).toBeUndefined() + expect(env.DYLD_INSERT_LIBRARIES).toBeUndefined() + expect(env.PYTHONSTARTUP).toBeUndefined() + }) + + test("owns and reaps a real local-runtime child instead of unrefing it", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-local-runtime-")) + const environment = path.join(root, "environment.json") + const pidfile = path.join(root, "runtime.pid") + const id = `fixture-${crypto.randomUUID()}` + const fixture = path.resolve(import.meta.dir, "../fixture/local-runtime-process.ts") + const saved = { + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, + MODAL_TOKEN_SECRET: process.env.MODAL_TOKEN_SECRET, + OPENSCIENCE_CONFIG_CONTENT: process.env.OPENSCIENCE_CONFIG_CONTENT, + } + process.env.OPENAI_API_KEY = "provider-host-secret" + process.env.AWS_SECRET_ACCESS_KEY = "cloud-host-secret" + process.env.MODAL_TOKEN_SECRET = "modal-host-secret" + process.env.OPENSCIENCE_CONFIG_CONTENT = "host-control-plane" + let pid = 0 + try { + const started = await LocalRuntime.start({ + id, + file: process.execPath, + args: [fixture, environment, pidfile], + timeoutMs: 5_000, + probe: async () => { + const value = await fs.readFile(pidfile, "utf8").catch(() => undefined) + return value ? ["fixture-model"] : null + }, + }) + expect(started).toEqual({ alreadyRunning: false, value: ["fixture-model"] }) + pid = Number(await fs.readFile(pidfile, "utf8")) + expect(pid).toBeGreaterThan(0) + const childEnv = JSON.parse(await fs.readFile(environment, "utf8")) as Record + expect(childEnv.OPENAI_API_KEY).toBeUndefined() + expect(childEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(childEnv.MODAL_TOKEN_SECRET).toBeUndefined() + expect(childEnv.OPENSCIENCE_CONFIG_CONTENT).toBeUndefined() + + expect(await LocalRuntime.stop(id)).toBe(true) + for (let attempt = 0; attempt < 200; attempt++) { + try { + process.kill(pid, 0) + } catch { + pid = 0 + break + } + await Bun.sleep(10) + } + expect(pid).toBe(0) + } finally { + await LocalRuntime.stop(id).catch(() => undefined) + if (pid) { + try { + process.kill(pid, "SIGKILL") + } catch {} + } + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + await fs.rm(root, { recursive: true, force: true }) + } + }, 15_000) + test("POST /models lists a running endpoint's models", async () => { const res = await app.request("/models", { method: "POST", diff --git a/backend/cli/test/server/settings-sandbox.test.ts b/backend/cli/test/server/settings-sandbox.test.ts index 3ac2b5de..9b0a5ac9 100644 --- a/backend/cli/test/server/settings-sandbox.test.ts +++ b/backend/cli/test/server/settings-sandbox.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import { SandboxSettingsRoutes } from "../../src/server/routes/settings/sandbox" import { Sandbox } from "../../src/sandbox/sandbox" +import os from "node:os" +import path from "node:path" const app = SandboxSettingsRoutes() @@ -39,4 +41,18 @@ describe("/settings/sandbox routes", () => { expect(body.ok).toBe(true) } }) + + test("PUT rejects non-absolute and over-broad writable roots without persisting them", async () => { + const before = await (await app.request("/")).json() + for (const value of ["relative/path", "/", os.homedir(), path.dirname(os.homedir())]) { + const response = await app.request("/", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowWrite: [value] }), + }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: expect.stringContaining("invalid or over-broad") }) + } + expect(await (await app.request("/")).json()).toEqual(before) + }) }) diff --git a/backend/cli/test/server/settings-storage.test.ts b/backend/cli/test/server/settings-storage.test.ts new file mode 100644 index 00000000..6f9b0d51 --- /dev/null +++ b/backend/cli/test/server/settings-storage.test.ts @@ -0,0 +1,418 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const routes = new URL("../../src/server/routes/settings/storage.ts", import.meta.url).href +const globalModule = new URL("../../src/global/index.ts", import.meta.url).href +const artifactModule = new URL("../../src/artifact/store.ts", import.meta.url).href +const leaseModule = new URL("../../src/util/file-lease.ts", import.meta.url).href +const logModule = new URL("../../src/util/log.ts", import.meta.url).href +const computeModule = new URL("../../src/compute/jobs.ts", import.meta.url).href +const instanceModule = new URL("../../src/project/instance.ts", import.meta.url).href +const trustModule = new URL("../../src/project/trust.ts", import.meta.url).href +const sessionModule = new URL("../../src/session/index.ts", import.meta.url).href +const configModule = new URL("../../src/config/config.ts", import.meta.url).href +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))) +}) + +async function root() { + const value = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-storage-")) + roots.push(value) + return value +} + +function isolatedEnv(root: string) { + return { + ...process.env, + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_STATE_HOME: path.join(root, "state"), + XDG_CACHE_HOME: path.join(root, "cache"), + } +} + +async function script(root: string, source: string, args: string[] = []) { + const filepath = path.join(root, `storage-${crypto.randomUUID()}.ts`) + await fs.writeFile(filepath, source) + const proc = Bun.spawn([process.execPath, filepath, ...args], { + cwd: root, + env: isolatedEnv(root), + stdout: "pipe", + stderr: "pipe", + }) + const [exit, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (exit !== 0) throw new Error(stderr || stdout || `storage helper exited ${exit}`) + return stdout.trim() +} + +async function waitFor(filepath: string) { + const deadline = Date.now() + 10_000 + while (!(await fs.lstat(filepath).catch(() => undefined))) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${filepath}`) + await Bun.sleep(20) + } +} + +describe("Storage Settings integration", () => { + test("rejects relative and nested destinations", async () => { + const workspace = await root() + const source = [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'const relative = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: "relative" }) })', + "if (relative.status !== 400) throw new Error(`expected relative 400, got ${relative.status}`)", + 'const nested = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: `${await Global.Path.dataTarget}/nested` }) })', + "if (nested.status !== 409) throw new Error(`expected nested 409, got ${nested.status}: ${await nested.text()}`)", + ].join("\n") + await script(workspace, source) + }) + + test("preserves workspace lockfiles and SQLite journals while dropping app transients", async () => { + const workspace = await root() + const target = path.join(workspace, "relocated") + const source = [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const target = process.argv.at(-1)", + 'const session = path.join(Global.Path.data, "workspaces", "prj_filter", "ses_filter")', + 'const storage = path.join(Global.Path.data, "storage", "filter")', + 'const artifactStore = path.join(Global.Path.data, "artifact-store")', + 'await Promise.all([fs.mkdir(session, { recursive: true }), fs.mkdir(storage, { recursive: true }), fs.mkdir(path.join(artifactStore, "partial"), { recursive: true })])', + 'await Promise.all([fs.writeFile(path.join(session, "bun.lock"), "bun"), fs.writeFile(path.join(session, "uv.lock"), "uv"), fs.writeFile(path.join(session, "analysis.db-wal"), "wal"), fs.writeFile(path.join(session, "analysis.db-shm"), "shm"), fs.writeFile(path.join(session, "report.partial"), "partial"), fs.writeFile(path.join(storage, "record.json.lock"), "stale lock"), fs.writeFile(path.join(storage, "record.json.123.tmp"), "stale temp"), fs.writeFile(path.join(artifactStore, "artifacts.db-wal"), "stale wal"), fs.writeFile(path.join(artifactStore, "artifacts.db-shm"), "stale shm"), fs.writeFile(path.join(artifactStore, "partial", "upload.partial"), "in flight")])', + 'const response = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: target }) })', + "if (response.status !== 200) throw new Error(`relocation failed ${response.status}: ${await response.text()}`)", + ].join("\n") + await script(workspace, source, [target]) + + const session = path.join(target, "workspaces", "prj_filter", "ses_filter") + expect(await fs.readFile(path.join(session, "bun.lock"), "utf8")).toBe("bun") + expect(await fs.readFile(path.join(session, "uv.lock"), "utf8")).toBe("uv") + expect(await fs.readFile(path.join(session, "analysis.db-wal"), "utf8")).toBe("wal") + expect(await fs.readFile(path.join(session, "analysis.db-shm"), "utf8")).toBe("shm") + expect(await fs.readFile(path.join(session, "report.partial"), "utf8")).toBe("partial") + expect(await Bun.file(path.join(target, "storage", "filter", "record.json.lock")).exists()).toBe(false) + expect(await Bun.file(path.join(target, "storage", "filter", "record.json.123.tmp")).exists()).toBe(false) + expect(await Bun.file(path.join(target, "artifact-store", "artifacts.db-wal")).exists()).toBe(false) + expect(await Bun.file(path.join(target, "artifact-store", "artifacts.db-shm")).exists()).toBe(false) + expect(await Bun.file(path.join(target, "artifact-store", "partial", "upload.partial")).exists()).toBe(false) + }) + + test("drains a sibling writer, snapshots WAL data, and switches its precomputed paths without restart", async () => { + const workspace = await root() + const target = path.join(workspace, "relocated") + const ready = path.join(workspace, "ready") + const release = path.join(workspace, "release") + const holderSource = [ + `import { Global } from ${JSON.stringify(globalModule)}`, + `import { FileLease } from ${JSON.stringify(leaseModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const ready = process.argv.at(-2)", + "const release = process.argv.at(-1)", + 'const record = path.join(Global.Path.data, "storage", "sibling-after.json")', + "await fs.mkdir(path.dirname(record), { recursive: true })", + 'await using lease = await FileLease.acquire(path.join(Global.Path.data, "storage", "held.lock"), 60_000)', + 'await fs.writeFile(ready, "ready")', + "while (!(await Bun.file(release).exists())) await Bun.sleep(10)", + 'await fs.writeFile(record, JSON.stringify({ side: "target" }))', + ].join("\n") + const holderFile = path.join(workspace, "holder.ts") + await fs.writeFile(holderFile, holderSource) + const holder = Bun.spawn([process.execPath, holderFile, ready, release], { + cwd: workspace, + env: isolatedEnv(workspace), + stdout: "pipe", + stderr: "pipe", + }) + await waitFor(ready) + + const moverSource = [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + `import { ArtifactStore } from ${JSON.stringify(artifactModule)}`, + `import { Log } from ${JSON.stringify(logModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const target = process.argv.at(-1)", + "await Log.init({ print: false, dev: true })", + 'Log.Default.info("before storage switch")', + "await Log.flush()", + 'await fs.mkdir(path.join(Global.Path.data, "storage"), { recursive: true })', + 'await fs.writeFile(path.join(Global.Path.data, "storage", "before.json"), JSON.stringify({ source: true }))', + 'const artifact = await ArtifactStore.save({ projectID: "prj_storage", sessionID: "ses_storage", sourcePath: "/result.txt", filename: "result.txt", kind: "document", content: new Blob(["immutable result"], { type: "text/plain" }) })', + 'const response = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: target }) })', + "if (response.status !== 200) throw new Error(`relocation failed ${response.status}: ${await response.text()}`)", + 'Log.Default.info("after storage switch")', + "await Log.flush()", + "console.log(JSON.stringify({ body: await response.json(), artifact: artifact.id }))", + ].join("\n") + const moverFile = path.join(workspace, "mover.ts") + await fs.writeFile(moverFile, moverSource) + const mover = Bun.spawn([process.execPath, moverFile, target], { + cwd: workspace, + env: isolatedEnv(workspace), + stdout: "pipe", + stderr: "pipe", + }) + await Bun.sleep(100) + expect(mover.exitCode).toBeNull() + await fs.writeFile(release, "release") + const [holderExit, moverExit, holderError, moverOut, moverError] = await Promise.all([ + holder.exited, + mover.exited, + new Response(holder.stderr).text(), + new Response(mover.stdout).text(), + new Response(mover.stderr).text(), + ]) + expect(holderExit, holderError).toBe(0) + expect(moverExit, moverError).toBe(0) + const moved = JSON.parse(moverOut.trim()) as { body: { target: string; files: number }; artifact: string } + expect(moved.body.target).toBe(await fs.realpath(target)) + expect(moved.body.files).toBeGreaterThan(0) + expect(await Bun.file(path.join(target, "storage", "before.json")).json()).toEqual({ source: true }) + expect(await Bun.file(path.join(target, "storage", "sibling-after.json")).json()).toEqual({ side: "target" }) + expect(await Bun.file(path.join(target, "log", "dev.log")).text()).toContain("before storage switch") + expect(await Bun.file(path.join(target, "log", "dev.log")).text()).toContain("after storage switch") + + const verifySource = [ + `import { ArtifactStore } from ${JSON.stringify(artifactModule)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'const item = await ArtifactStore.read("prj_storage", process.argv.at(-1))', + 'if (!item || await item.content.text() !== "immutable result") throw new Error("artifact snapshot failed")', + "if (await Global.Path.dataTarget !== process.argv.at(-2)) throw new Error(`wrong active root: ${await Global.Path.dataTarget}`)", + ].join("\n") + await script(workspace, verifySource, [await fs.realpath(target), moved.artifact]) + }) + + test("serializes queued relocations from the newly active root", async () => { + const workspace = await root() + const first = path.join(workspace, "first-target") + const second = path.join(workspace, "second-target") + const ready = path.join(workspace, "holder-ready") + const release = path.join(workspace, "holder-release") + const initial = path.join(workspace, "home", ".openscience") + await fs.mkdir(path.join(initial, "000-target-era"), { recursive: true }) + await fs.mkdir(path.join(initial, "zzz-copy-delay"), { recursive: true }) + const payload = Buffer.alloc(1024 * 1024, 7) + await Promise.all( + Array.from({ length: 48 }, (_, index) => + fs.writeFile(path.join(initial, "zzz-copy-delay", `${String(index).padStart(3, "0")}.bin`), payload), + ), + ) + + const holderFile = path.join(workspace, "relocation-holder.ts") + await fs.writeFile( + holderFile, + [ + `import { Global } from ${JSON.stringify(globalModule)}`, + `import { FileLease } from ${JSON.stringify(leaseModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const ready = process.argv.at(-2)", + "const release = process.argv.at(-1)", + 'await using lease = await FileLease.acquire(path.join(Global.Path.data, "queued-relocation.lock"), 60_000)', + 'await fs.writeFile(ready, "ready")', + "while (!(await Bun.file(release).exists())) await Bun.sleep(5)", + ].join("\n"), + ) + const holder = Bun.spawn([process.execPath, holderFile, ready, release], { + cwd: workspace, + env: isolatedEnv(workspace), + stdout: "pipe", + stderr: "pipe", + }) + await waitFor(ready) + + const moverFile = path.join(workspace, "queued-relocation.ts") + await fs.writeFile( + moverFile, + [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const target = process.argv.at(-2)", + 'const publish = process.argv.at(-1) === "publish"', + 'const response = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: target }) })', + "if (response.status !== 200) throw new Error(await response.text())", + 'if (publish) await fs.writeFile(path.join(Global.Path.data, "000-target-era", "after-first.txt"), "preserved")', + "console.log(JSON.stringify(await response.json()))", + ].join("\n"), + ) + const spawnMover = (target: string, mode: string) => + Bun.spawn([process.execPath, moverFile, target, mode], { + cwd: workspace, + env: isolatedEnv(workspace), + stdout: "pipe", + stderr: "pipe", + }) + const firstMove = spawnMover(first, "publish") + await waitFor(path.join(workspace, "config", "openscience", "data-root-switch.intent")) + const secondMove = spawnMover(second, "plain") + await fs.writeFile(release, "release") + const [holderCode, firstCode, secondCode, holderError, firstError, secondError] = await Promise.all([ + holder.exited, + firstMove.exited, + secondMove.exited, + new Response(holder.stderr).text(), + new Response(firstMove.stderr).text(), + new Response(secondMove.stderr).text(), + ]) + expect(holderCode, holderError).toBe(0) + expect(firstCode, firstError).toBe(0) + expect(secondCode, secondError).toBe(0) + expect(await fs.readFile(path.join(second, "000-target-era", "after-first.txt"), "utf8")).toBe("preserved") + }, 30_000) + + test.skipIf(process.platform !== "linux")( + "waits for a surviving local compute child after its owning server is SIGKILLed", + async () => { + const workspace = await root() + const project = path.join(workspace, "workspace") + const target = path.join(workspace, "relocated") + const ownerReady = path.join(project, "owner-ready.json") + const childReady = path.join(project, "child-ready") + const release = path.join(project, "release") + await fs.mkdir(project) + + const ownerFile = path.join(workspace, "compute-owner.ts") + await fs.writeFile( + ownerFile, + [ + `import { ComputeJobs } from ${JSON.stringify(computeModule)}`, + `import { Instance } from ${JSON.stringify(instanceModule)}`, + `import { ProjectTrust } from ${JSON.stringify(trustModule)}`, + `import { Session } from ${JSON.stringify(sessionModule)}`, + `import { Config } from ${JSON.stringify(configModule)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const [project, ownerReady, childReady, release] = process.argv.slice(-4)", + "const quote = (value) => `'${value.replaceAll(\"'\", \"'\\\"'\\\"'\")}'`", + "await Config.setSandbox({ enabled: false })", + "await Instance.provide({", + " directory: project,", + " fn: async () => {", + " const status = await ProjectTrust.status(Instance.project)", + " if (!status.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: status.root })", + " const session = await Session.create({})", + " const command = `printf child-ready > ${quote(childReady)}; while [ ! -f ${quote(release)} ]; do sleep 0.02; done; printf surviving-child`", + " const root = path.join(Global.Path.data, 'compute-runtime')", + " const job = await ComputeJobs.start({ name: 'survivor', command, target: { kind: 'local' }, sessionID: session.id }, { root, workspace: project })", + " const stored = await ComputeJobs.get(job.id, { root, workspace: project })", + " if (!stored?.pid || !stored.process_identity) throw new Error('compute identity was not persisted')", + " await fs.writeFile(ownerReady, JSON.stringify({ id: job.id, pid: stored.pid, identity: stored.process_identity }))", + " await new Promise(() => undefined)", + " },", + "})", + ].join("\n"), + ) + + const env = isolatedEnv(workspace) + const owner = Bun.spawn([process.execPath, ownerFile, project, ownerReady, childReady, release], { + cwd: workspace, + env, + stdout: "pipe", + stderr: "pipe", + }) + let childPID: number | undefined + try { + await waitFor(ownerReady) + await waitFor(childReady) + const running = (await Bun.file(ownerReady).json()) as { id: string; pid: number; identity: string } + childPID = running.pid + const operations = path.join(workspace, "config", "openscience", "data-root-operations") + const records = await Promise.all( + (await fs.readdir(operations)).map((name) => Bun.file(path.join(operations, name)).json()), + ) + expect(records).toContainEqual(expect.objectContaining({ pid: running.pid, identity: running.identity })) + + process.kill(owner.pid, "SIGKILL") + await owner.exited + expect(() => process.kill(running.pid, 0)).not.toThrow() + + const moverFile = path.join(workspace, "compute-mover.ts") + await fs.writeFile( + moverFile, + [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + "const target = process.argv.at(-1)", + 'const response = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: target }) })', + "if (response.status !== 200) throw new Error(await response.text())", + "console.log(await response.text())", + ].join("\n"), + ) + const mover = Bun.spawn([process.execPath, moverFile, target], { + cwd: workspace, + env, + stdout: "pipe", + stderr: "pipe", + }) + await Bun.sleep(200) + expect(mover.exitCode).toBeNull() + + await fs.writeFile(release, "release") + const [moverExit, moverOut, moverError] = await Promise.all([ + mover.exited, + new Response(mover.stdout).text(), + new Response(mover.stderr).text(), + ]) + expect(moverExit, moverError).toBe(0) + expect(JSON.parse(moverOut)).toMatchObject({ target: await fs.realpath(target) }) + const log = await fs.readFile(path.join(target, "compute-runtime", "jobs", `${running.id}.log`), "utf8") + expect(log).toContain("surviving-child") + } finally { + if (owner.exitCode === null) { + try { + process.kill(owner.pid, "SIGKILL") + } catch {} + } + if (childPID) { + try { + process.kill(-childPID, "SIGKILL") + } catch {} + } + } + }, + 30_000, + ) + + test("reset reverse-migrates target-era writes and preserves both safety copies", async () => { + const workspace = await root() + const target = path.join(workspace, "custom") + const flow = [ + `import { StorageRoutes } from ${JSON.stringify(routes)}`, + `import { Global } from ${JSON.stringify(globalModule)}`, + 'import fs from "node:fs/promises"', + 'import path from "node:path"', + "const target = process.argv.at(-1)", + 'await fs.writeFile(path.join(Global.Path.data, "default-only.txt"), "old default")', + 'let response = await StorageRoutes().request("/location", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: target }) })', + "if (response.status !== 200) throw new Error(await response.text())", + 'await fs.writeFile(path.join(Global.Path.data, "target-era.txt"), "kept")', + 'response = await StorageRoutes().request("/location", { method: "DELETE" })', + "if (response.status !== 200) throw new Error(await response.text())", + "const body = await response.json()", + 'if (!body.backup) throw new Error("reset did not preserve the prior default")', + 'if (await Bun.file(path.join(Global.Path.data, "target-era.txt")).text() !== "kept") throw new Error("target-era write was lost")', + 'if (await Bun.file(path.join(target, "target-era.txt")).text() !== "kept") throw new Error("custom safety copy was removed")', + 'if (await Bun.file(path.join(body.backup, "default-only.txt")).text() !== "old default") throw new Error("default safety copy was removed")', + 'if (await Bun.file(path.join(Global.Path.config, "data-location")).exists()) throw new Error("pointer survived reset")', + "console.log(JSON.stringify(body))", + ].join("\n") + const body = JSON.parse(await script(workspace, flow, [target])) as { target: string; backup: string } + expect(body.target).toBe(await fs.realpath(path.join(workspace, "home", ".openscience"))) + expect(body.backup).toContain(".pre-reset-") + }) +}) diff --git a/backend/cli/test/server/settings-updates.test.ts b/backend/cli/test/server/settings-updates.test.ts index dc3cf3c6..cdea806b 100644 --- a/backend/cli/test/server/settings-updates.test.ts +++ b/backend/cli/test/server/settings-updates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isNewerVersion } from "../../src/server/routes/settings/updates" +import { createUpdateCache, isNewerVersion } from "../../src/server/routes/settings/updates" describe("update version ordering", () => { test("only reports a genuinely newer release", () => { @@ -9,3 +9,58 @@ describe("update version ordering", () => { expect(isNewerVersion("local", "2.0.2")).toBe(false) }) }) + +describe("update snapshot cache", () => { + test("deduplicates concurrent and warm background checks", async () => { + let calls = 0 + let time = 1_000 + const cache = createUpdateCache({ + ttl: 500, + now: () => time, + load: async () => ++calls, + }) + + const [first, second] = await Promise.all([cache(), cache()]) + expect([first, second]).toEqual([1, 1]) + expect(await cache()).toBe(1) + expect(calls).toBe(1) + + time += 501 + expect(await cache()).toBe(2) + expect(calls).toBe(2) + }) + + test("refreshes explicitly and retries failures immediately", async () => { + let calls = 0 + const cache = createUpdateCache({ + load: async () => { + calls++ + if (calls === 1) throw new Error("registry unavailable") + return calls + }, + }) + + await expect(cache()).rejects.toThrow("registry unavailable") + expect(await cache()).toBe(2) + expect(await cache(true)).toBe(3) + }) + + test("deduplicates overlapping explicit refreshes", async () => { + let calls = 0 + const gate = Promise.withResolvers() + const cache = createUpdateCache({ + load: () => { + calls++ + return gate.promise + }, + }) + + const first = cache(true) + const second = cache(true) + expect(calls).toBe(0) + await Promise.resolve() + expect(calls).toBe(1) + gate.resolve(7) + expect(await Promise.all([first, second])).toEqual([7, 7]) + }) +}) diff --git a/backend/cli/test/session/command-shell.test.ts b/backend/cli/test/session/command-shell.test.ts new file mode 100644 index 00000000..39e427b7 --- /dev/null +++ b/backend/cli/test/session/command-shell.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Command } from "../../src/command" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir, trustProject } from "../fixture/fixture" + +async function commandFile(directory: string, shell: string) { + const root = path.join(directory, ".openscience", "command") + await fs.mkdir(root, { recursive: true }) + await Bun.write( + path.join(root, "review.md"), + [`---`, `description: Project review`, `---`, `!\`${shell}\``].join("\n"), + ) +} + +test("an untrusted project command cannot shadow a built-in or run shell interpolation", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const marker = path.join(directory, "untrusted-command-ran") + await commandFile(directory, `printf imported > ${JSON.stringify(marker)}`) + return marker + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const command = await Command.get("review") + expect(command?.description).not.toBe("Project review") + expect(await Bun.file(tmp.extra).exists()).toBe(false) + }, + }) +}) + +test("trusted command shell interpolation uses the governed shell boundary", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (directory) => { + const marker = path.join(directory, "trusted-command-ran") + await commandFile(directory, `printf governed > ${JSON.stringify(marker)}`) + return marker + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({ + permission: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + let failure: unknown + await SessionPrompt.command({ + sessionID: session.id, + command: "review", + arguments: "", + model: "test/model", + }).catch((error) => { + failure = error + }) + expect(await Bun.file(tmp.extra).exists(), failure instanceof Error ? failure.message : String(failure)).toBe( + true, + ) + expect(await Bun.file(tmp.extra).text()).toBe("governed") + }, + }) +}, 30_000) diff --git a/backend/cli/test/session/filesystem-grants.test.ts b/backend/cli/test/session/filesystem-grants.test.ts index 4b5130fb..6fdd83dd 100644 --- a/backend/cli/test/session/filesystem-grants.test.ts +++ b/backend/cli/test/session/filesystem-grants.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ComputeJobs } from "../../src/compute/jobs" +import { Bus } from "../../src/bus" import { File } from "../../src/file" import { PermissionNext } from "../../src/permission/next" import { InstanceBootstrap } from "../../src/project/bootstrap" @@ -33,6 +34,45 @@ async function wait(sessionID: string, attempt = 0): Promise { + test("does not broadcast a revocation for a new session's initial workspace", async () => { + await using external = await tmpdir() + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const changes: string[] = [] + const unsubscribe = Bus.subscribe(SessionFilesystem.Event.Changed, (event) => { + changes.push(event.properties.sessionID) + }) + const session = await Session.create({}) + await using cleanup = { + [Symbol.asyncDispose]: () => Session.remove(session.id), + } + + expect(changes).toEqual([]) + expect(await SessionFilesystem.list(session.id)).toContainEqual( + expect.objectContaining({ + path: tmp.path, + access: "write", + scope: "session", + source: "workspace", + }), + ) + + const grant = await SessionFilesystem.grant({ + sessionID: session.id, + path: external.path, + access: "read", + scope: "session", + }) + expect(changes).toEqual([session.id]) + await SessionFilesystem.revoke(session.id, grant.id) + expect(changes).toEqual([session.id, session.id]) + unsubscribe() + }, + }) + }) + test("creates a durable read-write workspace grant with each session", async () => { await using tmp = await tmpdir() await withSession(tmp.path, async (session) => { @@ -81,6 +121,8 @@ describe("session filesystem grants", () => { access: "write", }), ).rejects.toBeInstanceOf(SessionFilesystem.DeniedError) + expect(await SessionFilesystem.processReadRoots(session.id)).toContain(external.path) + expect(await SessionFilesystem.processWriteRoots(session.id)).not.toContain(external.path) }) }) @@ -97,6 +139,7 @@ describe("session filesystem grants", () => { access: "read", scope: "once", }) + expect(await SessionFilesystem.processReadRoots(session.id)).not.toContain(external.path) await expect( SessionFilesystem.authorize({ sessionID: session.id, @@ -144,7 +187,7 @@ describe("session filesystem grants", () => { }) }) - test("never turns an external write grant into a code-writable mount", async () => { + test("keeps process read and write grants directional", async () => { await using external = await tmpdir() await using tmp = await tmpdir() await withSession(tmp.path, async (session) => { @@ -156,11 +199,12 @@ describe("session filesystem grants", () => { }) const roots = await SessionFilesystem.processWriteRoots(session.id) expect(roots).toContain(tmp.path) - expect(roots).not.toContain(external.path) + expect(roots).toContain(external.path) + expect(await SessionFilesystem.processReadRoots(session.id)).toContain(external.path) expect((await SessionFilesystem.snapshot(session.id)).enforcement).toEqual({ broker: "enforced", - processWrite: "workspace_only", - processRead: "policy_only", + processWrite: "grant_only", + processRead: Sandbox.describe().readIsolation === "grant_only" ? "grant_only" : "policy_only", }) }) }) diff --git a/backend/cli/test/session/instruction.test.ts b/backend/cli/test/session/instruction.test.ts index 67719fa3..6003fe23 100644 --- a/backend/cli/test/session/instruction.test.ts +++ b/backend/cli/test/session/instruction.test.ts @@ -3,6 +3,7 @@ import path from "path" import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import { Network } from "../../src/settings/network" describe("InstructionPrompt.resolve", () => { test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { @@ -24,6 +25,37 @@ describe("InstructionPrompt.resolve", () => { }) }) + test("remote instructions use network policy and refuse a loopback redirect", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "openscience.json"), + JSON.stringify({ instructions: ["https://example.com/instructions"] }), + ) + }, + }) + const original = globalThis.fetch + const calls: string[] = [] + await Network.set({ allowlistEnabled: true, enabled: [], custom: ["example.com"] }) + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)) + return new Response(null, { status: 302, headers: { location: "http://127.0.0.1:4096/secret" } }) + }) as typeof fetch + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await InstructionPrompt.system() + expect(result.some((entry) => entry.includes("example.com/instructions"))).toBe(false) + expect(calls).toEqual(["https://example.com/instructions"]) + }, + }) + } finally { + globalThis.fetch = original + await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) + } + }) + test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/backend/cli/test/session/removal-ack.test.ts b/backend/cli/test/session/removal-ack.test.ts new file mode 100644 index 00000000..695c8bfd --- /dev/null +++ b/backend/cli/test/session/removal-ack.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Storage } from "../../src/storage/storage" +import { tmpdir } from "../fixture/fixture" + +test("session deletion tombstones rejected cleanup and succeeds on retry before erasing data", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const messageKey = ["message", session.id, "msg_deletion_tombstone"] + await Storage.write(messageKey, { id: "msg_deletion_tombstone", retained: true }) + let attempts = 0 + let resourceAlive = true + const unsubscribe = Bus.subscribe(Session.Event.Deleted, async () => { + attempts++ + if (attempts === 1) throw new Error("runtime reaper rejected") + resourceAlive = false + }) + try { + await expect(Session.remove(session.id)).rejects.toThrow("runtime reaper rejected") + expect(resourceAlive).toBe(true) + expect(await Storage.read<{ id: string; retained: boolean }>(messageKey)).toEqual({ + id: "msg_deletion_tombstone", + retained: true, + }) + await expect(Session.get(session.id)).rejects.toBeInstanceOf(Storage.NotFoundError) + await expect(Session.createNext({ id: session.id, directory: tmp.path })).rejects.toBeInstanceOf( + Session.DeletingError, + ) + + await Session.remove(session.id) + expect(attempts).toBe(2) + expect(resourceAlive).toBe(false) + await expect(Storage.read(messageKey)).rejects.toBeInstanceOf(Storage.NotFoundError) + + // Successful completion removes the tombstone, so an explicit import + // may reuse the historical id only after cleanup has been acknowledged. + const replacement = await Session.createNext({ id: session.id, directory: tmp.path }) + expect(replacement.id).toBe(session.id) + await Session.remove(replacement.id) + } finally { + unsubscribe() + } + }, + }) +}) diff --git a/backend/cli/test/settings/network.test.ts b/backend/cli/test/settings/network.test.ts index e03921c3..bab454c6 100644 --- a/backend/cli/test/settings/network.test.ts +++ b/backend/cli/test/settings/network.test.ts @@ -1,5 +1,8 @@ import { afterEach, expect, test } from "bun:test" import { Network } from "../../src/settings/network" +import { NetworkSettingsRoutes } from "../../src/server/routes/settings/network" +import { Global } from "../../src/global" +import path from "node:path" afterEach(async () => { await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) @@ -11,6 +14,158 @@ test("domainAllowed accepts exact domains and subdomains only", () => { expect(Network.domainAllowed("badexample.com", ["example.com"])).toBe(false) }) +test("new installs enforce every curated package and science group", async () => { + const state = Network.defaults() + expect(state.allowlistEnabled).toBe(true) + expect(state.enabled).toEqual(Network.CATALOG.map((group) => group.id)) + await Network.set(state) + + await expect(Network.assertAllowed("https://files.pythonhosted.org/pkg.whl")).resolves.toBeUndefined() + await expect(Network.assertAllowed("https://api.openalex.org/works")).resolves.toBeUndefined() + await expect(Network.assertAllowed("https://unknown.example/data")).rejects.toThrow("allow-list") +}) + +test("migrates only the legacy unenforced seed and preserves an explicit v2 disable", async () => { + const file = path.join(Global.Path.data, "settings", "network.json") + await Bun.write(file, JSON.stringify({ allowlistEnabled: false, enabled: ["package-management"], custom: [] })) + expect(await Network.get()).toEqual(Network.defaults()) + expect((await Bun.file(file).json()).version).toBe(2) + + await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) + expect(await Network.get()).toEqual({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) +}) + +test("custom domains canonicalize and invalid policy input fails closed", async () => { + expect(Network.canonicalDomain("Research.Example.")).toBe("research.example") + await expect(Network.set({ allowlistEnabled: true, enabled: ["unknown-group"], custom: [] })).rejects.toThrow( + "Unknown network group", + ) + + for (const invalid of [ + "https://example.com", + "example.com/path", + "*.example.com", + "example.com:443", + "127.0.0.1", + "localhost", + "service.local", + ]) { + expect(() => Network.canonicalDomain(invalid), invalid).toThrow() + } +}) + +test("loopback and literal IP destinations stay blocked when enforcement is disabled", async () => { + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + await expect(Network.blocked("http://localhost:4096/private")).rejects.toThrow("loopback") + await expect(Network.blocked("http://127.0.0.1:4096/private")).rejects.toThrow() + await expect(Network.blocked("http://[::1]:4096/private")).rejects.toThrow() +}) + +test("policy-aware fetch reauthorizes redirects and strips cross-origin credentials", async () => { + const original = globalThis.fetch + const calls: Array<{ url: string; headers: Headers }> = [] + await Network.set({ allowlistEnabled: true, enabled: [], custom: ["first.test"] }) + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const current = String(input) + calls.push({ url: current, headers: new Headers(init?.headers) }) + if (current.startsWith("https://first.test")) { + return new Response(null, { status: 302, headers: { Location: "https://second.test/final" } }) + } + return new Response("ok") + }) as unknown as typeof fetch + + try { + const resolveAddresses = async () => ["93.184.216.34"] + await expect( + Network.fetch( + "https://first.test/start", + { headers: { Authorization: "Bearer secret", Cookie: "a=b" } }, + { resolveAddresses }, + ), + ).rejects.toThrow("second.test") + expect(calls).toHaveLength(1) + + const approved: string[] = [] + const response = await Network.fetch( + "https://first.test/start", + { headers: { Authorization: "Bearer secret", Cookie: "a=b" } }, + { + authorize: async ({ host }) => { + approved.push(host) + }, + resolveAddresses, + }, + ) + expect(await response.text()).toBe("ok") + expect(approved).toEqual(["second.test"]) + expect(calls.at(-1)?.headers.get("authorization")).toBeNull() + expect(calls.at(-1)?.headers.get("cookie")).toBeNull() + } finally { + globalThis.fetch = original + } +}) + +test("policy-aware fetch blocks redirects to loopback before opening a second socket", async () => { + const original = globalThis.fetch + const calls: string[] = [] + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)) + return new Response(null, { status: 302, headers: { Location: "http://127.0.0.1:4096/private" } }) + }) as unknown as typeof fetch + try { + await expect( + Network.fetch("https://public.test/start", {}, { resolveAddresses: async () => ["93.184.216.34"] }), + ).rejects.toThrow() + expect(calls).toEqual(["https://public.test/start"]) + } finally { + globalThis.fetch = original + } +}) + +test("policy-aware fetch rejects private DNS answers before opening a socket", async () => { + const original = globalThis.fetch + let calls = 0 + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + globalThis.fetch = (async () => { + calls++ + return new Response("should not run") + }) as unknown as typeof fetch + try { + for (const address of ["127.0.0.1", "10.0.0.8", "169.254.169.254", "::1", "fc00::1"]) { + await expect( + Network.fetch("https://public.example/resource", {}, { resolveAddresses: async () => [address] }), + ).rejects.toThrow("non-public address") + } + expect(calls).toBe(0) + } finally { + globalThis.fetch = original + } +}) + +test("policy-aware fetch pins the validated address instead of resolving twice", async () => { + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + let resolutions = 0 + const connected: string[] = [] + const response = await Network.fetch( + "https://public.example/resource", + {}, + { + resolveAddresses: async () => { + resolutions++ + return resolutions === 1 ? ["8.8.8.8"] : ["127.0.0.1"] + }, + transport: async (_target, _init, address) => { + connected.push(address) + return new Response("pinned") + }, + }, + ) + expect(await response.text()).toBe("pinned") + expect(resolutions).toBe(1) + expect(connected).toEqual(["8.8.8.8"]) +}) + test("assertAllowed is advisory when the allow-list is disabled", async () => { await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) await expect(Network.assertAllowed("https://blocked.test/resource")).resolves.toBeUndefined() @@ -22,3 +177,26 @@ test("assertAllowed blocks hosts outside the effective allow-list", async () => await expect(Network.assertAllowed("https://api.example.com/resource")).resolves.toBeUndefined() await expect(Network.assertAllowed("https://blocked.test/resource")).rejects.toThrow("allow-list") }) + +test("settings GET and PUT round-trip backend-confirmed state and reject invalid hosts", async () => { + const app = NetworkSettingsRoutes() + const update = await app.request("/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ allowlistEnabled: true, enabled: ["package-management"], custom: ["Lab.Example."] }), + }) + expect(update.status).toBe(200) + expect((await update.json()).state.custom).toEqual(["lab.example"]) + + const current = await app.request("/") + const payload = (await current.json()) as { state: Network.State; allowlist: string[] } + expect(payload.state.custom).toEqual(["lab.example"]) + expect(payload.allowlist).toContain("lab.example") + + const invalid = await app.request("/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ allowlistEnabled: true, enabled: [], custom: ["http://localhost:4096"] }), + }) + expect(invalid.status).toBe(400) +}) diff --git a/backend/cli/test/storage/interprocess-authority.test.ts b/backend/cli/test/storage/interprocess-authority.test.ts new file mode 100644 index 00000000..22c204a8 --- /dev/null +++ b/backend/cli/test/storage/interprocess-authority.test.ts @@ -0,0 +1,162 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" + +const runner = path.resolve(import.meta.dir, "../fixture/authority-process.ts") + +function environment(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: root, + XDG_DATA_HOME: path.join(root, "xdg-data"), + XDG_CONFIG_HOME: path.join(root, "xdg-config"), + XDG_CACHE_HOME: path.join(root, "xdg-cache"), + XDG_STATE_HOME: path.join(root, "xdg-state"), + } +} + +async function run(root: string, ...args: string[]) { + const proc = Bun.spawn([process.execPath, runner, ...args], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [exit, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + if (exit !== 0) throw new Error(`child ${args[0]} exited ${exit}: ${stderr}`) +} + +test("storage mutations and authority signals cross real process boundaries", async () => { + await using tmp = await tmpdir() + await run(tmp.path, "init") + + await Promise.all([run(tmp.path, "update", "40"), run(tmp.path, "update", "40")]) + const counter = await Bun.file(path.join(tmp.path, "data", "storage", "interprocess", "counter.json")).json() + expect(counter).toEqual({ count: 80 }) + + const ready = path.join(tmp.path, "watch-ready") + const result = path.join(tmp.path, "watch-result.json") + const watcher = Bun.spawn([process.execPath, runner, "watch", ready, result], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(tmp.path), + stdout: "pipe", + stderr: "pipe", + }) + for (let attempt = 0; attempt < 100; attempt++) { + if (await Bun.file(ready).exists()) break + await new Promise((resolve) => setTimeout(resolve, 20)) + } + expect(await Bun.file(ready).exists()).toBe(true) + await run(tmp.path, "publish", "project-cross-process") + const [exit, stderr] = await Promise.all([watcher.exited, new Response(watcher.stderr).text()]) + expect(stderr).toBe("") + expect(exit).toBe(0) + expect(await fs.readFile(result, "utf8").then(JSON.parse)).toMatchObject({ + type: "event", + event: { kind: "trust", projectID: "project-cross-process", denied: true }, + }) +}, 15_000) + +test("authority lease remains held until an async critical section settles", async () => { + await using tmp = await tmpdir() + const ready = path.join(tmp.path, "lease-ready") + const release = path.join(tmp.path, "lease-release") + const acquired = path.join(tmp.path, "lease-acquired") + const holder = Bun.spawn([process.execPath, runner, "hold", ready, release], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(tmp.path), + stdout: "pipe", + stderr: "pipe", + }) + for (let attempt = 0; attempt < 100; attempt++) { + if (await Bun.file(ready).exists()) break + await Bun.sleep(10) + } + expect(await Bun.file(ready).exists()).toBe(true) + + const waiter = Bun.spawn([process.execPath, runner, "acquire", acquired], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(tmp.path), + stdout: "pipe", + stderr: "pipe", + }) + await Bun.sleep(100) + expect(await Bun.file(acquired).exists()).toBe(false) + + await Bun.write(release, "release") + const [holderExit, waiterExit, holderError, waiterError] = await Promise.all([ + holder.exited, + waiter.exited, + new Response(holder.stderr).text(), + new Response(waiter.stderr).text(), + ]) + expect({ holderExit, waiterExit, holderError, waiterError }).toEqual({ + holderExit: 0, + waiterExit: 0, + holderError: "", + waiterError: "", + }) + expect(await Bun.file(acquired).text()).toBe("acquired") +}, 15_000) + +test("a watcher replays an unacknowledged durable denial on startup", async () => { + await using tmp = await tmpdir() + await run(tmp.path, "publish", "project-pending-startup") + const ready = path.join(tmp.path, "pending-watch-ready") + const result = path.join(tmp.path, "pending-watch-result.json") + const watcher = Bun.spawn([process.execPath, runner, "watch", ready, result], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(tmp.path), + stdout: "pipe", + stderr: "pipe", + }) + const [exit, stderr] = await Promise.all([watcher.exited, new Response(watcher.stderr).text()]) + expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" }) + expect(await fs.readFile(result, "utf8").then(JSON.parse)).toMatchObject({ + type: "event", + event: { kind: "trust", projectID: "project-pending-startup", denied: true }, + }) +}, 15_000) + +test("a newer mutation cannot erase an older unacknowledged cleanup", async () => { + await using tmp = await tmpdir() + await run(tmp.path, "publish", "project-first-pending") + await run(tmp.path, "publish", "project-second-pending") + + const watch = async (projectID: string) => { + const ready = path.join(tmp.path, `${projectID}-watch-ready`) + const result = path.join(tmp.path, `${projectID}-watch-result.json`) + const watcher = Bun.spawn([process.execPath, runner, "watch-project", projectID, ready, result], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(tmp.path), + stdout: "pipe", + stderr: "pipe", + }) + const [exit, stderr] = await Promise.all([watcher.exited, new Response(watcher.stderr).text()]) + expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" }) + return fs.readFile(result, "utf8").then(JSON.parse) + } + + expect(await watch("project-second-pending")).toMatchObject({ + type: "event", + revision: 2, + event: { projectID: "project-second-pending" }, + }) + let signal = await Bun.file(path.join(tmp.path, "data", "storage", "authority", "revision.json")).json() + expect(signal).toMatchObject({ revision: 2, pending: false }) + expect(signal.backlog).toEqual([ + expect.objectContaining({ revision: 1, event: expect.objectContaining({ projectID: "project-first-pending" }) }), + ]) + + expect(await watch("project-first-pending")).toMatchObject({ + type: "event", + revision: 1, + event: { projectID: "project-first-pending" }, + }) + + signal = await Bun.file(path.join(tmp.path, "data", "storage", "authority", "revision.json")).json() + expect(signal).toMatchObject({ revision: 2, pending: false, backlog: [] }) +}, 15_000) diff --git a/backend/cli/test/tool/apply_patch.test.ts b/backend/cli/test/tool/apply_patch.test.ts index e11ae886..fbe22738 100644 --- a/backend/cli/test/tool/apply_patch.test.ts +++ b/backend/cli/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import { FileTrash } from "../../src/file/trash" const baseCtx = { sessionID: "test", @@ -74,7 +75,7 @@ describe("tool.apply_patch freeform", () => { await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") }) - test("applies add/update/delete in one patch", async () => { + test("rejects multi-file patches before permission or side effects", async () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() @@ -89,32 +90,35 @@ describe("tool.apply_patch freeform", () => { const patchText = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" - const result = await execute({ patchText }, ctx) + await expect(execute({ patchText }, ctx)).rejects.toThrow("multi-file patches are not atomic") + expect(calls).toEqual([]) + await expect(fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")).rejects.toThrow() + expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nline2\n") + expect(await fs.readFile(deletePath, "utf-8")).toBe("obsolete\n") + expect(await FileTrash.list(Instance.project.id)).toEqual([]) + }, + }) + }) - expect(result.title).toContain("Success. Updated the following files") - expect(result.output).toContain("Success. Updated the following files") - expect(result.metadata.diff).toContain("Index:") - expect(calls.length).toBe(1) + test("deletes one file into recoverable trash", async () => { + await using fixture = await tmpdir({ git: true }) + const { ctx, calls } = makeCtx() - // Verify permission metadata includes files array for UI rendering - const permissionCall = calls[0] - expect(permissionCall.metadata.files).toHaveLength(3) - expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"]) - - const addFile = permissionCall.metadata.files.find((f) => f.type === "add") - expect(addFile).toBeDefined() - expect(addFile!.relativePath).toBe("nested/new.txt") - expect(addFile!.after).toBe("created\n") - - const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") - expect(updateFile).toBeDefined() - expect(updateFile!.before).toContain("line2") - expect(updateFile!.after).toContain("changed") - - const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") - expect(added).toBe("created\n") - expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") - await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "delete.txt") + await fs.writeFile(target, "obsolete\n", "utf8") + const result = await execute({ patchText: "*** Begin Patch\n*** Delete File: delete.txt\n*** End Patch" }, ctx) + + expect(calls).toHaveLength(1) + expect(calls[0]?.metadata.files).toMatchObject([{ type: "delete", before: "obsolete\n", after: "" }]) + expect(result.metadata.trash).toHaveLength(1) + expect(result.output).toContain("Recoverable for 30 days: ftr_") + await expect(fs.readFile(target)).rejects.toThrow() + expect(await FileTrash.list(Instance.project.id)).toMatchObject([ + { id: result.metadata.trash[0]?.id, originalPath: target, state: "trash" }, + ]) }, }) }) @@ -145,6 +149,7 @@ describe("tool.apply_patch freeform", () => { expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) expect(moveFile.before).toBe("old content\n") expect(moveFile.after).toBe("new content\n") + expect(await FileTrash.list(Instance.project.id)).toHaveLength(1) }, }) }) @@ -233,7 +238,7 @@ describe("tool.apply_patch freeform", () => { }) }) - test("moves file overwriting existing destination", async () => { + test("refuses to move over an existing destination", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() @@ -250,15 +255,15 @@ describe("tool.apply_patch freeform", () => { const patchText = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" - await execute({ patchText }, ctx) - - await expect(fs.readFile(original, "utf-8")).rejects.toThrow() - expect(await fs.readFile(destination, "utf-8")).toBe("new\n") + await expect(execute({ patchText }, ctx)).rejects.toThrow("Refusing to overwrite") + expect(await fs.readFile(original, "utf-8")).toBe("from\n") + expect(await fs.readFile(destination, "utf-8")).toBe("existing\n") + expect(await FileTrash.list(Instance.project.id)).toEqual([]) }, }) }) - test("adds file overwriting existing file", async () => { + test("refuses to add over an existing file", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() @@ -270,8 +275,72 @@ describe("tool.apply_patch freeform", () => { const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" - await execute({ patchText }, ctx) - expect(await fs.readFile(target, "utf-8")).toBe("new content\n") + await expect(execute({ patchText }, ctx)).rejects.toThrow("Refusing to overwrite") + expect(await fs.readFile(target, "utf-8")).toBe("old content\n") + }, + }) + }) + + test("refuses an add destination that appears during approval", async () => { + await using fixture = await tmpdir() + const target = path.join(fixture.path, "appeared.txt") + const ctx: ToolCtx = { + ...baseCtx, + ask: async () => { + await fs.writeFile(target, "concurrent owner\n") + }, + } + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Add File: appeared.txt\n+agent bytes\n*** End Patch" + await expect(execute({ patchText }, ctx)).rejects.toThrow("Refusing to overwrite") + expect(await fs.readFile(target, "utf8")).toBe("concurrent owner\n") + }, + }) + }) + + test("refuses changed bytes after edit approval", async () => { + await using fixture = await tmpdir() + const target = path.join(fixture.path, "changed.txt") + await fs.writeFile(target, "approved\n") + const ctx: ToolCtx = { + ...baseCtx, + ask: async () => { + await fs.writeFile(target, "concurrent\n") + }, + } + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Update File: changed.txt\n@@\n-approved\n+agent\n*** End Patch" + await expect(execute({ patchText }, ctx)).rejects.toThrow("changed after approval") + expect(await fs.readFile(target, "utf8")).toBe("concurrent\n") + }, + }) + }) + + test("refuses a replacement inode even when bytes match approval", async () => { + await using fixture = await tmpdir() + const target = path.join(fixture.path, "identity.txt") + await fs.writeFile(target, "approved\n") + const ctx: ToolCtx = { + ...baseCtx, + ask: async () => { + const replacement = path.join(fixture.path, "replacement.txt") + await fs.writeFile(replacement, "approved\n") + await fs.rename(replacement, target) + }, + } + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Update File: identity.txt\n@@\n-approved\n+agent\n*** End Patch" + await expect(execute({ patchText }, ctx)).rejects.toThrow("identity changed after approval") + expect(await fs.readFile(target, "utf8")).toBe("approved\n") }, }) }) diff --git a/backend/cli/test/tool/biology-notebook-concurrency.test.ts b/backend/cli/test/tool/biology-notebook-concurrency.test.ts new file mode 100644 index 00000000..f7a3cc8d --- /dev/null +++ b/backend/cli/test/tool/biology-notebook-concurrency.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { Session } from "../../src/session" +import { NotebookTool, releaseBiologySession, shutdownBiologyKernels } from "../../src/tool/biology/notebook" +import { tmpdir, trustProject } from "../fixture/fixture" + +const context = (sessionID: string) => ({ + sessionID, + messageID: "message_biology_concurrency", + callID: `call_${crypto.randomUUID()}`, + agent: "biology", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, +}) + +async function entries(sessionID: string) { + return Bun.file(AuthorityProcessLedger.pathForTests()) + .json() + .then( + (value) => + (value as Array<{ kind: string; owner_pid: number; project_id: string; session_id: string }>).filter( + (entry) => + entry.kind === "biology" && + entry.owner_pid === process.pid && + entry.project_id === Instance.project.id && + entry.session_id === sessionID, + ), + () => [], + ) +} + +test("legacy biology serializes first-kernel creation and cell results per session", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const tool = await NotebookTool.init() + try { + const [bootOne, bootTwo] = await Promise.all([ + tool.execute({ code: "print('boot-one')", timeout: 30_000 }, context(session.id)), + tool.execute({ code: "print('boot-two')", timeout: 30_000 }, context(session.id)), + ]) + expect(bootOne.output.trim()).toBe("boot-one") + expect(bootTwo.output.trim()).toBe("boot-two") + expect(await entries(session.id)).toHaveLength(1) + + const [slow, fast] = await Promise.all([ + tool.execute( + { code: "import time\ntime.sleep(0.2)\nprint('slow-result')", timeout: 30_000 }, + context(session.id), + ), + tool.execute({ code: "print('fast-result')", timeout: 30_000 }, context(session.id)), + ]) + expect(slow.output.trim()).toBe("slow-result") + expect(fast.output.trim()).toBe("fast-result") + expect(await entries(session.id)).toHaveLength(1) + } finally { + await releaseBiologySession(Instance.project.id, session.id) + await Session.remove(session.id) + } + expect(await entries(session.id)).toHaveLength(0) + }, + }) + shutdownBiologyKernels() +}, 60_000) diff --git a/backend/cli/test/tool/command-runtime-multiprocess.test.ts b/backend/cli/test/tool/command-runtime-multiprocess.test.ts new file mode 100644 index 00000000..c25ab411 --- /dev/null +++ b/backend/cli/test/tool/command-runtime-multiprocess.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +function environment(root: string) { + return { + ...process.env, + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +async function waitForExit(pid: number) { + for (let attempt = 0; attempt < 150; attempt++) { + if (!alive(pid)) return + await Bun.sleep(20) + } + throw new Error(`orphan command ${pid} remained alive after project trust revocation`) +} + +test("another server's trust revocation kills orphaned Bash and session-shell process groups", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-command-orphan-")) + const workspace = path.join(root, "workspace") + const runner = path.join(root, "runner.ts") + const instance = new URL("../../src/project/instance.ts", import.meta.url).href + const bootstrap = new URL("../../src/project/bootstrap.ts", import.meta.url).href + const trust = new URL("../../src/project/trust.ts", import.meta.url).href + const session = new URL("../../src/session/index.ts", import.meta.url).href + const prompt = new URL("../../src/session/prompt.ts", import.meta.url).href + const bash = new URL("../../src/tool/bash.ts", import.meta.url).href + const commands = new URL("../../src/science/command/registry.ts", import.meta.url).href + await fs.mkdir(workspace) + await Bun.write( + runner, + ` +import { Instance } from ${JSON.stringify(instance)} +import { InstanceBootstrap } from ${JSON.stringify(bootstrap)} +import { ProjectTrust } from ${JSON.stringify(trust)} +import { Session } from ${JSON.stringify(session)} +import { SessionPrompt } from ${JSON.stringify(prompt)} +import { BashTool } from ${JSON.stringify(bash)} +import { CommandRuntime } from ${JSON.stringify(commands)} + +const mode = process.argv[2] +const workspace = process.argv[3] +if (mode === "owner") { + const surface = process.argv[4] + await Instance.provide({ + directory: workspace, + fn: async () => { + const trust = await ProjectTrust.status(Instance.project) + if (!trust.canExecuteProjectCode) await ProjectTrust.update(Instance.project, { trusted: true, root: trust.root }) + const session = await Session.create({ title: surface }) + if (surface === "bash") { + const tool = await BashTool.init() + void tool.execute({ command: "sleep 60", description: "orphan regression" }, { + sessionID: session.id, + messageID: "message_orphan", + callID: "call_orphan", + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, + }).catch(() => undefined) + } else { + void SessionPrompt.shell({ + sessionID: session.id, + agent: "research", + model: { providerID: "test", modelID: "test" }, + command: "sleep 60", + }).catch(() => undefined) + } + for (let attempt = 0; attempt < 300; attempt++) { + const command = CommandRuntime.list(Instance.project.id, session.id)[0] + if (command) { + console.log(JSON.stringify({ pid: command.process_id, projectID: Instance.project.id, sessionID: session.id })) + await new Promise(() => {}) + } + await Bun.sleep(10) + } + throw new Error("command did not start") + }, + }) +} else if (mode === "revoke") { + await Instance.provide({ + directory: workspace, + init: InstanceBootstrap, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + }, + }) +} else { + throw new Error("unknown runner mode") +} +`, + ) + + const run = (mode: "owner" | "revoke", surface?: "bash" | "session-shell") => + Bun.spawn([process.execPath, runner, mode, workspace, ...(surface ? [surface] : [])], { + cwd: path.resolve(import.meta.dir, "../.."), + env: environment(root), + stdout: "pipe", + stderr: "pipe", + }) + + try { + for (const surface of ["bash", "session-shell"] as const) { + const owner = run("owner", surface) + const line = await new Promise((resolve, reject) => { + let buffered = "" + const timeout = setTimeout(() => reject(new Error(`${surface} owner did not report a child`)), 15_000) + const reader = owner.stdout.getReader() + void (async () => { + const decoder = new TextDecoder() + while (true) { + const chunk = await reader.read() + if (chunk.done) return + buffered += decoder.decode(chunk.value, { stream: true }) + const complete = buffered.split("\n").find((item) => item.trim().startsWith("{")) + if (!complete) continue + clearTimeout(timeout) + resolve(complete) + return + } + })().catch(reject) + owner.exited.then(async (code) => { + if (code !== 0) { + const stderr = await new Response(owner.stderr).text() + reject(new Error(`${surface} owner exited before registration: ${stderr}`)) + } + }) + }) + const registered = JSON.parse(line) as { pid: number } + expect(alive(registered.pid)).toBe(true) + owner.kill("SIGKILL") + await owner.exited + expect(alive(registered.pid)).toBe(true) + + const revoker = run("revoke") + const [code, stderr] = await Promise.all([revoker.exited, new Response(revoker.stderr).text()]) + expect(code, stderr).toBe(0) + await waitForExit(registered.pid) + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 60_000) diff --git a/backend/cli/test/tool/command-runtime.test.ts b/backend/cli/test/tool/command-runtime.test.ts index d04764c0..d2d43b79 100644 --- a/backend/cli/test/tool/command-runtime.test.ts +++ b/backend/cli/test/tool/command-runtime.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "bun:test" +import { spawn } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" import { Instance } from "../../src/project/instance" +import { WindowsJobLauncher } from "../../src/process/windows-job-launcher" import { CommandRuntime } from "../../src/science/command/registry" +import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/bash" import { executionSession, tmpdir } from "../fixture/fixture" @@ -44,9 +51,158 @@ test("bash registers only its live process in the project compute ledger", async state: "running", process_id: expect.any(Number), }) - expect(await CommandRuntime.stop(command.id, Instance.project.id, session.id)).toBe(true) + expect(await CommandRuntime.stopSession(Instance.project.id, "session_other")).toBe(0) + expect(await CommandRuntime.stopProject("project_other")).toBe(0) + expect(await CommandRuntime.stopProject(Instance.project.id)).toBe(1) expect((await running).output).toContain("User aborted the command") expect(CommandRuntime.list(Instance.project.id, session.id)).toEqual([]) }, }) }, 30_000) + +test("credential revocation stops every real registered command", async () => { + const wrapped = WindowsJobLauncher.wrap({ + file: process.execPath, + args: ["-e", "console.log(process.env.LAB_ACCESS_TOKEN); setInterval(() => {}, 1000)"], + }) + const child = spawn(wrapped.file, wrapped.args, { + env: { ...process.env, LAB_ACCESS_TOKEN: "inherited-command-secret" }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + let exited = false + const entry = await CommandRuntime.start( + { + projectID: "project_credentials", + sessionID: "session_credentials", + messageID: "message_credentials", + callID: "call_credentials", + description: "Credential-bearing command", + command: "credential-child", + }, + child, + () => Shell.killTree(child, { exited: () => exited, detached: process.platform !== "win32" }), + { windowsRelease: wrapped.release }, + ) + child.once("exit", () => { + exited = true + CommandRuntime.finish(entry.id) + }) + + const inherited = await new Promise((resolve, reject) => { + child.stdout!.once("data", (data) => resolve(String(data).trim())) + child.once("error", reject) + }) + expect(inherited).toBe("inherited-command-secret") + expect(await CommandRuntime.stopAll()).toBe(1) + expect(child.exitCode !== null || child.signalCode !== null).toBe(true) + expect(CommandRuntime.list("project_credentials", "session_credentials")).toEqual([]) +}) + +const posixTest = process.platform === "win32" ? test.skip : test + +posixTest("command completion reaps a same-group background descendant", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-command-descendant-")) + const marker = path.join(root, "descendant.pid") + const release = path.join(root, "release") + const wrapped = WindowsJobLauncher.wrap({ + file: "/bin/sh", + args: [ + "-c", + 'sleep 600 & printf "%s" "$!" > "$1"; while [ ! -f "$2" ]; do sleep 0.02; done; exit 0', + "command-runtime", + marker, + release, + ], + }) + const child = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + let descendantPID = 0 + let descendantIdentity: string | undefined + const projectID = `project-command-${crypto.randomUUID()}` + const sessionID = `session-command-${crypto.randomUUID()}` + try { + const entry = await CommandRuntime.start( + { + projectID, + sessionID, + messageID: "message-background", + description: "Background descendant regression", + command: "sleep 600 & exit", + }, + child, + () => Shell.killTree(child, { exited: () => child.exitCode !== null, detached: true }), + { windowsRelease: wrapped.release }, + ) + for (let attempt = 0; attempt < 200 && !(await Bun.file(marker).exists()); attempt++) await Bun.sleep(10) + descendantPID = Number((await Bun.file(marker).text()).trim()) + descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + expect(descendantIdentity).toMatch(/^[a-f0-9]{64}$/) + await Bun.write(release, "release") + await new Promise((resolve, reject) => { + child.once("exit", () => resolve()) + child.once("error", reject) + }) + CommandRuntime.finish(entry.id) + for (let attempt = 0; attempt < 200; attempt++) { + if (!(await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) break + await Bun.sleep(10) + } + + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(false) + expect(CommandRuntime.list(projectID, sessionID)).toEqual([]) + } finally { + await CommandRuntime.stopProject(projectID).catch(() => undefined) + if (descendantPID && (await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) { + process.kill(descendantPID, "SIGKILL") + } + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } +}) + +posixTest("command revocation reaps a direct child that starts a new session", async () => { + const python = Bun.which("python3") + if (!python) return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-command-setsid-")) + const marker = path.join(root, "descendant.pid") + const script = [ + "import subprocess, sys, time", + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'], start_new_session=True)", + "open(sys.argv[1], 'w').write(str(child.pid))", + "time.sleep(600)", + ].join("; ") + const wrapped = WindowsJobLauncher.wrap({ file: python, args: ["-c", script, marker] }) + const child = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + let descendantPID = 0 + let descendantIdentity: string | undefined + const projectID = `project-command-setsid-${crypto.randomUUID()}` + try { + const entry = await CommandRuntime.start( + { + projectID, + sessionID: "session-command-setsid", + messageID: "message-command-setsid", + description: "New session descendant regression", + command: "python start_new_session", + }, + child, + () => Shell.killTree(child, { exited: () => child.exitCode !== null, detached: true }), + { windowsRelease: wrapped.release }, + ) + for (let attempt = 0; attempt < 200 && !(await Bun.file(marker).exists()); attempt++) await Bun.sleep(10) + expect(await Bun.file(marker).exists()).toBe(true) + descendantPID = Number((await Bun.file(marker).text()).trim()) + descendantIdentity = await CredentialProcessLedger.identity(descendantPID) + expect(descendantIdentity).toMatch(/^[a-f0-9]{64}$/) + + expect(await CommandRuntime.stop(entry.id, projectID, "session-command-setsid")).toBe(true) + expect(await CredentialProcessLedger.owns(descendantPID, descendantIdentity)).toBe(false) + } finally { + await CommandRuntime.stopProject(projectID).catch(() => undefined) + if (descendantPID && (await CredentialProcessLedger.owns(descendantPID, descendantIdentity))) { + process.kill(descendantPID, "SIGKILL") + } + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/backend/cli/test/tool/modal.test.ts b/backend/cli/test/tool/modal.test.ts index bcf8a290..515b009d 100644 --- a/backend/cli/test/tool/modal.test.ts +++ b/backend/cli/test/tool/modal.test.ts @@ -16,3 +16,19 @@ test("requires the agent to choose a Modal timeout", async () => { expect(modal.parameters.safeParse(input).success).toBe(false) expect(modal.parameters.safeParse({ ...input, timeout_minutes: 15 }).success).toBe(true) }) + +test("dispatches asynchronously unless waiting is explicitly requested", async () => { + const modal = await ModalTool.init() + const input = { + name: "analysis", + command: "python analysis.py", + uploads: ["analysis.py"], + outputs: [], + packages: [], + gpu: "none", + timeout_minutes: 15, + } + + expect(modal.parameters.parse(input).wait).toBe(false) + expect(modal.parameters.parse({ ...input, wait: true }).wait).toBe(true) +}) diff --git a/backend/cli/test/tool/named-kernels.test.ts b/backend/cli/test/tool/named-kernels.test.ts index ce356048..17864fc3 100644 --- a/backend/cli/test/tool/named-kernels.test.ts +++ b/backend/cli/test/tool/named-kernels.test.ts @@ -27,6 +27,9 @@ test("kernel tools advertise and validate isolated managed names", async () => { expect(python.parameters.parse({ code: "1 + 1", kernel: "descriptive-eda" }).kernel).toBe("descriptive-eda") expect(r.parameters.parse({ code: "1 + 1", kernel: "stratified_rates" }).kernel).toBe("stratified_rates") expect(() => python.parameters.parse({ code: "1 + 1", kernel: "invalid name" })).toThrow() + expect(python.parameters.parse({ code: "1 + 1", environment: "nbody" }).environment).toBe("nbody") + expect(() => python.parameters.parse({ code: "1 + 1", environment: "../nbody" })).toThrow("path separators") + expect(() => r.parameters.parse({ code: "1 + 1", environment: "r-4.5" })).toThrow("Unrecognized key") }) test("four named notebook calls own four live managed kernels", async () => { diff --git a/backend/cli/test/tool/plan-mode.test.ts b/backend/cli/test/tool/plan-mode.test.ts index db5075b9..f5e91047 100644 --- a/backend/cli/test/tool/plan-mode.test.ts +++ b/backend/cli/test/tool/plan-mode.test.ts @@ -158,6 +158,7 @@ describe("tool.plan-mode", () => { await Bun.write( path.join(root, "unsafe.ts"), [ + `await Bun.write(${JSON.stringify(marker)}, "imported")`, "export default {", " description: 'unsafe custom tool',", " args: {},", @@ -178,9 +179,7 @@ describe("tool.plan-mode", () => { fn: async () => { const tools = await ToolRegistry.tools({ modelID: "", providerID: "" }) const tool = tools.find((item) => item.id === "unsafe") - expect(tool).toBeDefined() - const error = await denied(() => tool!.execute({}, context("plan"))) - expect(error.tool).toBe("unsafe") + expect(tool).toBeUndefined() expect(await Bun.file(tmp.extra).exists()).toBe(false) }, }) diff --git a/backend/cli/test/tool/read.test.ts b/backend/cli/test/tool/read.test.ts index 8c5c491f..e1e53539 100644 --- a/backend/cli/test/tool/read.test.ts +++ b/backend/cli/test/tool/read.test.ts @@ -155,6 +155,37 @@ describe("tool.read external_directory permission", () => { }, }) }) + + test("refuses a file swapped to a symlink during read approval", async () => { + if (process.platform === "win32") return + await using outside = await tmpdir({ + init: (dir) => Bun.write(path.join(dir, "secret.txt"), "must remain private"), + }) + await using tmp = await tmpdir({ + init: (dir) => Bun.write(path.join(dir, "target.txt"), "approved public bytes"), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = path.join(tmp.path, "target.txt") + const read = await ReadTool.init() + await expect( + read.execute( + { filePath: target }, + { + ...ctx, + ask: async (request) => { + if (request.permission !== "read") return + await fs.unlink(target) + await fs.symlink(path.join(outside.path, "secret.txt"), target) + }, + }, + ), + ).rejects.toThrow("symbolic link") + expect(await fs.readFile(path.join(outside.path, "secret.txt"), "utf8")).toBe("must remain private") + }, + }) + }) }) describe("tool.read env file permissions", () => { diff --git a/backend/cli/test/tool/registry.test.ts b/backend/cli/test/tool/registry.test.ts index 7eaa8489..06b9ea8e 100644 --- a/backend/cli/test/tool/registry.test.ts +++ b/backend/cli/test/tool/registry.test.ts @@ -4,6 +4,12 @@ import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" +import { ProjectTrust } from "../../src/project/trust" + +async function trustProject() { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) +} describe("tool.registry", () => { test("includes the native Atlas host broker", async () => { @@ -65,6 +71,7 @@ describe("tool.registry", () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const ids = await ToolRegistry.ids() expect(ids).toContain("hello") }, @@ -99,6 +106,7 @@ describe("tool.registry", () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await trustProject() const ids = await ToolRegistry.ids() expect(ids).toContain("hello") }, diff --git a/backend/cli/test/tool/webfetch-network.test.ts b/backend/cli/test/tool/webfetch-network.test.ts index 7856a33a..7ce62baa 100644 --- a/backend/cli/test/tool/webfetch-network.test.ts +++ b/backend/cli/test/tool/webfetch-network.test.ts @@ -3,6 +3,8 @@ import { Network } from "../../src/settings/network" import { WebFetchTool } from "../../src/tool/webfetch" import type { Tool } from "../../src/tool/tool" +const realFetch = globalThis.fetch + function context(ask: Tool.Context["ask"]): Tool.Context { return { sessionID: "session_test", @@ -17,6 +19,7 @@ function context(ask: Tool.Context["ask"]): Tool.Context { } afterEach(async () => { + globalThis.fetch = realFetch await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) }) @@ -52,3 +55,29 @@ test("Network.blocked and Network.allow round-trip the allow-list", async () => expect(await Network.blocked("https://other.test")).toBeUndefined() await expect(Network.blocked("not a url")).rejects.toThrow("Invalid network URL") }) + +test("webfetch asks for every blocked redirect target before following it", async () => { + await Network.set({ allowlistEnabled: true, enabled: [], custom: ["example.com"] }) + const calls: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + calls.push(url) + if (url.startsWith("https://example.com")) { + return new Response(null, { status: 302, headers: { Location: "https://example.org/result" } }) + } + return new Response("result", { headers: { "content-type": "text/plain" } }) + }) as typeof fetch + const asked: Parameters[0][] = [] + const webfetch = await WebFetchTool.init() + const result = await webfetch.execute( + { url: "https://example.com/start", format: "markdown" }, + context(async (input) => { + asked.push(input) + }), + ) + + expect(result.output).toBe("result") + expect(calls).toHaveLength(2) + expect(asked.map((item) => item.permission)).toEqual(["webfetch", "network"]) + expect(asked[1]?.patterns).toEqual(["example.org"]) +}) diff --git a/backend/cli/test/tool/write-safety.test.ts b/backend/cli/test/tool/write-safety.test.ts new file mode 100644 index 00000000..81de6f3e --- /dev/null +++ b/backend/cli/test/tool/write-safety.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { WriteTool } from "../../src/tool/write" +import { EditTool } from "../../src/tool/edit" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { FileTime } from "../../src/file/time" + +const base = { + sessionID: "test", + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, +} + +test("write refuses a target swapped to a symlink during approval", async () => { + if (process.platform === "win32") return + await using outside = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "secret.txt"), "secret\n") }) + await using tmp = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "target.txt"), "old\n") }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = path.join(tmp.path, "target.txt") + FileTime.read(base.sessionID, target) + const tool = await WriteTool.init() + await expect( + tool.execute( + { filePath: target, content: "agent\n" }, + { + ...base, + ask: async (request) => { + if (request.permission !== "edit") return + await fs.unlink(target) + await fs.symlink(path.join(outside.path, "secret.txt"), target) + }, + }, + ), + ).rejects.toThrow("symbolic link") + expect(await fs.readFile(path.join(outside.path, "secret.txt"), "utf8")).toBe("secret\n") + }, + }) +}) + +test("write refuses a new target that appears during approval", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = path.join(tmp.path, "new.txt") + const tool = await WriteTool.init() + await expect( + tool.execute( + { filePath: target, content: "agent\n" }, + { + ...base, + ask: async (request) => { + if (request.permission === "edit") await fs.writeFile(target, "concurrent\n") + }, + }, + ), + ).rejects.toThrow("unapproved file") + expect(await fs.readFile(target, "utf8")).toBe("concurrent\n") + }, + }) +}) + +test("edit refuses content and symlink swaps after approval", async () => { + if (process.platform === "win32") return + await using outside = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "secret.txt"), "secret\n") }) + await using tmp = await tmpdir({ init: (dir) => Bun.write(path.join(dir, "target.txt"), "old value\n") }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = path.join(tmp.path, "target.txt") + FileTime.read(base.sessionID, target) + const tool = await EditTool.init() + await expect( + tool.execute( + { filePath: target, oldString: "old", newString: "new" }, + { + ...base, + ask: async (request) => { + if (request.permission !== "edit") return + await fs.unlink(target) + await fs.symlink(path.join(outside.path, "secret.txt"), target) + }, + }, + ), + ).rejects.toThrow("symbolic link") + expect(await fs.readFile(path.join(outside.path, "secret.txt"), "utf8")).toBe("secret\n") + + await fs.unlink(target) + await fs.writeFile(target, "old value\n") + FileTime.read(base.sessionID, target) + await expect( + tool.execute( + { filePath: target, oldString: "old", newString: "new" }, + { + ...base, + ask: async (request) => { + if (request.permission === "edit") await fs.writeFile(target, "concurrent value\n") + }, + }, + ), + ).rejects.toThrow("changed after approval") + expect(await fs.readFile(target, "utf8")).toBe("concurrent value\n") + }, + }) +}) diff --git a/backend/cli/test/util/file-lease.test.ts b/backend/cli/test/util/file-lease.test.ts new file mode 100644 index 00000000..e4ea9d69 --- /dev/null +++ b/backend/cli/test/util/file-lease.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { FileLease } from "../../src/util/file-lease" +import { tmpdir } from "../fixture/fixture" + +test("a waiter follows exact-owner progress instead of timing out a healthy lease queue", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "progress.lock") + const record = (token: string) => JSON.stringify({ pid: process.pid, token, created: Date.now() }) + + await fs.writeFile(filepath, record("owner-a")) + const waiting = FileLease.acquire(filepath, 500) + await Bun.sleep(300) + await fs.writeFile(filepath, record("owner-b")) + await Bun.sleep(300) + await fs.rm(filepath) + + await using lease = await waiting + expect(await Bun.file(filepath).exists()).toBe(true) + void lease +}, 5_000) + +test("a waiter still fails closed when one live owner stops making progress", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "stuck.lock") + await fs.writeFile(filepath, JSON.stringify({ pid: process.pid, token: "unchanged-owner", created: Date.now() })) + + await expect(FileLease.acquire(filepath, 75)).rejects.toThrow( + "Timed out waiting for another OpenScience process to release", + ) +}, 5_000) diff --git a/bun.lock b/bun.lock index d76d161b..895cda6b 100644 --- a/bun.lock +++ b/bun.lock @@ -157,6 +157,7 @@ "@typescript/native-preview": "catalog:", "dompurify": "3.4.11", "fuzzysort": "catalog:", + "iconoir": "7.12.1", "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", @@ -1407,6 +1408,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "iconoir": ["iconoir@7.12.1", "", {}, "sha512-7ei4jd1bss0Ukyz/bbM9Zc96aLfZuwTEkSW8u5fx5h3X9912MsnmWwPw9LyTUtcf0ShfTzQeQ9oG7IslW3hrLg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/frontend/ui/package.json b/frontend/ui/package.json index 3dd58533..a2a27605 100644 --- a/frontend/ui/package.json +++ b/frontend/ui/package.json @@ -43,17 +43,18 @@ }, "dependencies": { "@kobalte/core": "catalog:", - "@synsci/sdk": "workspace:*", - "@synsci/util": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", + "@synsci/sdk": "workspace:*", + "@synsci/util": "workspace:*", "@typescript/native-preview": "catalog:", "dompurify": "3.4.11", "fuzzysort": "catalog:", + "iconoir": "7.12.1", "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", diff --git a/frontend/ui/src/components/basic-tool.css b/frontend/ui/src/components/basic-tool.css index 2c6bfeb6..ddd0524b 100644 --- a/frontend/ui/src/components/basic-tool.css +++ b/frontend/ui/src/components/basic-tool.css @@ -49,10 +49,6 @@ line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-base); - - &.capitalize { - text-transform: capitalize; - } } [data-slot="basic-tool-tool-subtitle"] { diff --git a/frontend/ui/src/components/button.css b/frontend/ui/src/components/button.css index d9b34592..28fcf7df 100644 --- a/frontend/ui/src/components/button.css +++ b/frontend/ui/src/components/button.css @@ -10,6 +10,16 @@ cursor: default; outline: none; white-space: nowrap; + transition: + background-color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); + + &:active:not(:disabled) { + transform: scale(0.98); + } &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -93,8 +103,6 @@ } &:active:not(:disabled) { background-color: var(--button-secondary-base); - scale: 0.99; - transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -109,7 +117,7 @@ } &[data-size="small"] { - height: 22px; + height: 32px; padding: 0 8px; &[data-icon] { padding: 0 12px 0 4px; @@ -129,8 +137,8 @@ } &[data-size="normal"] { - height: 24px; - line-height: 24px; + height: 32px; + line-height: 32px; padding: 0 6px; &[data-icon] { padding: 0 12px 0 4px; @@ -148,7 +156,7 @@ } &[data-size="large"] { - height: 32px; + height: 36px; padding: 6px 12px; &[data-icon] { @@ -170,3 +178,9 @@ outline: none; } } + +@media (pointer: coarse) { + [data-component="button"] { + min-height: 44px; + } +} diff --git a/frontend/ui/src/components/checkbox.css b/frontend/ui/src/components/checkbox.css index b10ebbbd..609b52dd 100644 --- a/frontend/ui/src/components/checkbox.css +++ b/frontend/ui/src/components/checkbox.css @@ -1,5 +1,6 @@ [data-component="checkbox"] { display: flex; + min-height: 32px; align-items: center; gap: 12px; cursor: default; @@ -119,3 +120,10 @@ pointer-events: none; } } + +@media (pointer: coarse) { + [data-component="checkbox"] { + min-width: 44px; + min-height: 44px; + } +} diff --git a/frontend/ui/src/components/collapsible.css b/frontend/ui/src/components/collapsible.css index 57c903d2..a0ebf9ee 100644 --- a/frontend/ui/src/components/collapsible.css +++ b/frontend/ui/src/components/collapsible.css @@ -48,7 +48,7 @@ display: flex; align-items: center; justify-content: center; - border-radius: 6px; + border-radius: var(--radius-xs); color: var(--icon-weak); } } diff --git a/frontend/ui/src/components/dialog.tsx b/frontend/ui/src/components/dialog.tsx index 8b549eab..5d52d250 100644 --- a/frontend/ui/src/components/dialog.tsx +++ b/frontend/ui/src/components/dialog.tsx @@ -1,7 +1,6 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" import { useI18n } from "../context/i18n" -import { useDialogLite } from "../context/dialog" import { IconButton } from "./icon-button" export interface DialogProps extends ParentProps { @@ -13,26 +12,21 @@ export interface DialogProps extends ParentProps { classList?: ComponentProps<"div">["classList"] fit?: boolean transition?: boolean + role?: "dialog" | "alertdialog" } export function Dialog(props: DialogProps) { const i18n = useI18n() - // In lite mode the parent dialog wrapper doesn't mount a Kobalte root — - // we render plain divs in place of Kobalte.* primitives so nothing tries - // to read context that isn't there. - const lite = useDialogLite() const Header = (
- {props.title}
}> - {props.title} -
+ {props.title} {props.action} - + - - {props.description} - - } - > - - {props.description} - - + + {props.description} + ) @@ -71,45 +56,27 @@ export function Dialog(props: DialogProps) { data-transition={props.transition ? true : undefined} >
- - {Header} - {Description} -
{props.children}
-
- } + { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} > - { - const target = e.currentTarget as HTMLElement | null - const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null - if (autofocusEl) { - e.preventDefault() - autofocusEl.focus() - } - }} - > - {Header} - {Description} -
{props.children}
-
- + {Header} + {Description} +
{props.children}
+
) diff --git a/frontend/ui/src/components/dropdown-menu.css b/frontend/ui/src/components/dropdown-menu.css index cba04161..113efe84 100644 --- a/frontend/ui/src/components/dropdown-menu.css +++ b/frontend/ui/src/components/dropdown-menu.css @@ -17,11 +17,8 @@ } &[data-closed] { - animation: dropdown-menu-close 0.15s ease-out; - } - - &[data-expanded] { - animation: dropdown-menu-open 0.15s ease-out; + pointer-events: none; + animation: dropdown-menu-close var(--duration-fast) ease-in forwards; } } @@ -102,17 +99,6 @@ } } -@keyframes dropdown-menu-open { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} - @keyframes dropdown-menu-close { from { opacity: 1; diff --git a/frontend/ui/src/components/hover-card.css b/frontend/ui/src/components/hover-card.css index 02d1f10a..f18b0120 100644 --- a/frontend/ui/src/components/hover-card.css +++ b/frontend/ui/src/components/hover-card.css @@ -9,7 +9,7 @@ min-width: 200px; max-width: 320px; max-height: calc(100vh - 1rem); - border-radius: 8px; + border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); pointer-events: auto; @@ -24,11 +24,12 @@ } &[data-closed] { - animation: hover-card-close 0.15s ease-out; + pointer-events: none; + animation: hover-card-close var(--duration-fast) ease-in forwards; } &[data-expanded] { - animation: hover-card-open 0.15s ease-out; + animation: hover-card-open var(--duration-slow) var(--ease-out-expo); } [data-slot="hover-card-body"] { diff --git a/frontend/ui/src/components/icon-button.css b/frontend/ui/src/components/icon-button.css index aa550e99..e4f5be78 100644 --- a/frontend/ui/src/components/icon-button.css +++ b/frontend/ui/src/components/icon-button.css @@ -7,6 +7,15 @@ user-select: none; aspect-ratio: 1; flex-shrink: 0; + transition: + background-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); + + &:active:not(:disabled) { + transform: scale(0.98); + } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -112,8 +121,8 @@ } &[data-size="normal"] { - width: 24px; - height: 24px; + width: 32px; + height: 32px; font-size: var(--font-size-small); line-height: var(--line-height-large); @@ -121,7 +130,7 @@ } &[data-size="large"] { - height: 32px; + height: 36px; /* padding: 0 8px 0 6px; */ gap: 8px; @@ -138,3 +147,20 @@ outline: none; } } + +@media (prefers-reduced-motion: reduce) { + [data-component="icon-button"] { + transition: none; + + &:active:not(:disabled) { + transform: none; + } + } +} + +@media (pointer: coarse) { + [data-component="icon-button"] { + min-width: 44px; + min-height: 44px; + } +} diff --git a/frontend/ui/src/components/icon-button.tsx b/frontend/ui/src/components/icon-button.tsx index f1832ce7..28f82769 100644 --- a/frontend/ui/src/components/icon-button.tsx +++ b/frontend/ui/src/components/icon-button.tsx @@ -4,13 +4,14 @@ import { Icon, IconProps } from "./icon" export interface IconButtonProps extends ComponentProps { icon: IconProps["name"] + "aria-label": string size?: "normal" | "large" iconSize?: IconProps["size"] variant?: "primary" | "secondary" | "ghost" } export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { - const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"]) + const [split, rest] = splitProps(props, ["icon", "variant", "size", "iconSize", "class", "classList"]) return ( & IconButtonProps) { [split.class ?? ""]: !!split.class, }} > - + ) } diff --git a/frontend/ui/src/components/icon-system.test.ts b/frontend/ui/src/components/icon-system.test.ts new file mode 100644 index 00000000..bee14c8e --- /dev/null +++ b/frontend/ui/src/components/icon-system.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { iconDefinitions, iconSpecs } from "./iconoir-registry" + +const read = (name: string) => readFileSync(fileURLToPath(new URL(name, import.meta.url)), "utf8") + +describe("shared Iconoir system", () => { + test("renders one decorative 24px coordinate system", () => { + const source = read("./icon.tsx") + + expect(source).toContain('viewBox="0 0 24 24"') + expect(source).toContain('preserveAspectRatio="xMidYMid meet"') + expect(source).toContain("data-icon={local.name}") + expect(source).toContain("data-icon-source={definition().source}") + expect(source.match(/aria-hidden="true"/g)).toHaveLength(2) + }) + + test("bundles only the explicit Iconoir subset", () => { + const registry = read("./iconoir-registry.ts") + const pkg = JSON.parse(read("../../package.json")) as { dependencies: Record } + + expect(pkg.dependencies.iconoir).toBe("7.12.1") + expect(registry.match(/from "iconoir\/icons\/.+\.svg\?raw"/g)).toHaveLength(97) + expect(registry).not.toContain("iconoir.css") + expect(registry).not.toContain("iconoir-regular.css") + expect(registry).not.toContain("fetch(") + }) + + test("covers the stable public API with distinct semantic glyphs", () => { + expect(Object.keys(iconSpecs)).toHaveLength(110) + expect(new Set(Object.values(iconSpecs).map((entry) => entry.source)).size).toBe(97) + + expect(iconSpecs.models.source).toBe("brain-electricity") + expect(iconSpecs.providers.source).toBe("database-settings") + expect(iconSpecs.task.source).toBe("task-list") + expect(iconSpecs.split.source).toBe("vertical-split") + expect(iconSpecs.network.source).toBe("network") + expect(iconSpecs.artifact.source).toBe("reports") + expect(iconSpecs.file.source).toBe("page") + expect(iconSpecs["folder-tree"].source).toBe("network-reverse") + + const concepts = ["models", "providers", "task", "split", "network", "artifact", "file", "folder-tree"] as const + expect(new Set(concepts.map((name) => iconSpecs[name].source)).size).toBe(concepts.length) + }) + + test("extracts trusted local SVG bodies without nesting or remote loading", () => { + for (const definition of Object.values(iconDefinitions)) { + expect(definition.body.length).toBeGreaterThan(0) + expect(definition.body).not.toContain(" { + const styles = read("./icon.css") + + expect(styles).toContain("--icon-size: 18px") + expect(styles).toContain("--icon-stroke-width: 1.5") + expect(styles).toContain('[data-size="small"]') + expect(styles).toContain("--icon-size: 16px") + expect(styles).toContain('[data-size="medium"]') + expect(styles).toContain("--icon-size: 20px") + expect(styles).toContain("stroke-width: var(--icon-stroke-width)") + expect(styles).toContain("stroke-linecap: round") + expect(styles).toContain("stroke-linejoin: round") + expect(styles).toContain("pointer-events: none") + }) + + test("keeps provider and file brands on their dedicated sprite systems", () => { + const provider = read("./provider-icon.tsx") + const file = read("./file-icon.tsx") + + expect(provider).toContain('import sprite from "./provider-icons/sprite.svg"') + expect(file).toContain('import sprite from "./file-icons/sprite.svg"') + }) + + test("gives icon controls a Fitts-safe target independent of glyph size", () => { + const styles = read("./icon-button.css") + + expect(styles).toContain("width: 32px") + expect(styles).toContain("height: 32px") + expect(styles).toContain("min-width: 44px") + expect(styles).toContain("min-height: 44px") + }) +}) diff --git a/frontend/ui/src/components/icon.css b/frontend/ui/src/components/icon.css index a2ebee30..f0c30b52 100644 --- a/frontend/ui/src/components/icon.css +++ b/frontend/ui/src/components/icon.css @@ -1,34 +1,54 @@ [data-component="icon"] { + --icon-size: 18px; + --icon-stroke-width: 1.5; + display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - /* resize: both; */ - aspect-ratio: 1/1; - color: var(--icon-base); + width: var(--icon-size); + height: var(--icon-size); + aspect-ratio: 1; + line-height: 0; + color: currentColor; + pointer-events: none; + vertical-align: middle; &[data-size="small"] { - width: 16px; - height: 16px; + --icon-size: 16px; } &[data-size="normal"] { - width: 20px; - height: 20px; + --icon-size: 18px; } &[data-size="medium"] { - width: 24px; - height: 24px; + --icon-size: 20px; } &[data-size="large"] { - width: 24px; - height: 24px; + --icon-size: 24px; } [data-slot="icon-svg"] { + display: block; width: 100%; - height: auto; + height: 100%; + overflow: visible; + stroke-width: var(--icon-stroke-width); + shape-rendering: geometricPrecision; + } + + [data-slot="icon-svg"] :where([stroke]) { + stroke-width: var(--icon-stroke-width); + } + + [data-slot="icon-svg"] :where(path, line, polyline, polygon, rect, circle, ellipse) { + stroke-linecap: round; + stroke-linejoin: round; + } + + &[data-icon="stop"] [data-slot="icon-svg"] :where(path, rect) { + fill: currentColor; } } diff --git a/frontend/ui/src/components/icon.tsx b/frontend/ui/src/components/icon.tsx index 40749cd1..f9e74b44 100644 --- a/frontend/ui/src/components/icon.tsx +++ b/frontend/ui/src/components/icon.tsx @@ -1,96 +1,24 @@ import { splitProps, type ComponentProps } from "solid-js" - -const icons = { - "align-right": ``, - "arrow-up": ``, - "arrow-left": ``, - "arrow-right": ``, - archive: ``, - "bubble-5": ``, - brain: ``, - "bullet-list": ``, - "check-small": ``, - "chevron-down": ``, - "chevron-right": ``, - "chevron-grabber-vertical": ``, - "chevron-double-right": ``, - "circle-x": ``, - close: ``, - "close-small": ``, - checklist: ``, - console: ``, - expand: ``, - collapse: ``, - code: ``, - "code-lines": ``, - "circle-ban-sign": ``, - "edit-small-2": ``, - eye: ``, - enter: ``, - folder: ``, - "magnifying-glass": ``, - "plus-small": ``, - plus: ``, - pin: ``, - "pin-filled": ``, - "pencil-line": ``, - mcp: ``, - glasses: ``, - "magnifying-glass-menu": ``, - "window-cursor": ``, - task: ``, - stop: ``, - undo: ``, - "layout-left": ``, - "layout-left-partial": ``, - "layout-left-full": ``, - "layout-right": ``, - "layout-right-partial": ``, - "layout-right-full": ``, - "square-arrow-top-right": ``, - "speech-bubble": ``, - comment: ``, - "folder-add-left": ``, - github: ``, - discord: ``, - "layout-bottom": ``, - "layout-bottom-partial": ``, - "layout-bottom-full": ``, - "dot-grid": ``, - "circle-check": ``, - copy: ``, - check: ``, - photo: ``, - share: ``, - download: ``, - menu: ``, - server: ``, - branch: ``, - edit: ``, - help: ``, - "settings-gear": ``, - dash: ``, - cloud: ``, - "cloud-upload": ``, - trash: ``, - sliders: ``, - keyboard: ``, - selector: ``, - "arrow-down-to-line": ``, - link: ``, - providers: ``, - models: ``, -} +import { iconDefinitions, type IconName } from "./iconoir-registry" export interface IconProps extends ComponentProps<"svg"> { - name: keyof typeof icons + name: IconName size?: "small" | "normal" | "medium" | "large" } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) + const definition = () => iconDefinitions[local.name] + return ( -
+ ) diff --git a/frontend/ui/src/components/iconoir-registry.ts b/frontend/ui/src/components/iconoir-registry.ts new file mode 100644 index 00000000..f349f519 --- /dev/null +++ b/frontend/ui/src/components/iconoir-registry.ts @@ -0,0 +1,345 @@ +import activitySvg from "iconoir/icons/activity.svg?raw" +import alignRightSvg from "iconoir/icons/align-right.svg?raw" +import archiveSvg from "iconoir/icons/archive.svg?raw" +import arrowLeftSvg from "iconoir/icons/arrow-left.svg?raw" +import arrowRightSvg from "iconoir/icons/arrow-right.svg?raw" +import arrowSeparateVerticalSvg from "iconoir/icons/arrow-separate-vertical.svg?raw" +import arrowUpSvg from "iconoir/icons/arrow-up.svg?raw" +import atomSvg from "iconoir/icons/atom.svg?raw" +import attachmentSvg from "iconoir/icons/attachment.svg?raw" +import brainSvg from "iconoir/icons/brain.svg?raw" +import brainElectricitySvg from "iconoir/icons/brain-electricity.svg?raw" +import brainResearchSvg from "iconoir/icons/brain-research.svg?raw" +import chatBubbleEmptySvg from "iconoir/icons/chat-bubble-empty.svg?raw" +import checkSvg from "iconoir/icons/check.svg?raw" +import checkCircleSvg from "iconoir/icons/check-circle.svg?raw" +import clockSvg from "iconoir/icons/clock.svg?raw" +import cloudSvg from "iconoir/icons/cloud.svg?raw" +import cloudUploadSvg from "iconoir/icons/cloud-upload.svg?raw" +import codeSvg from "iconoir/icons/code.svg?raw" +import codeBracketsSvg from "iconoir/icons/code-brackets.svg?raw" +import codeBracketsSquareSvg from "iconoir/icons/code-brackets-square.svg?raw" +import collapseSvg from "iconoir/icons/collapse.svg?raw" +import copySvg from "iconoir/icons/copy.svg?raw" +import cpuSvg from "iconoir/icons/cpu.svg?raw" +import dashboardDotsSvg from "iconoir/icons/dashboard-dots.svg?raw" +import databaseSvg from "iconoir/icons/database.svg?raw" +import databaseSettingsSvg from "iconoir/icons/database-settings.svg?raw" +import discordSvg from "iconoir/icons/discord.svg?raw" +import downloadSvg from "iconoir/icons/download.svg?raw" +import editPencilSvg from "iconoir/icons/edit-pencil.svg?raw" +import expandSvg from "iconoir/icons/expand.svg?raw" +import eyeSvg from "iconoir/icons/eye.svg?raw" +import fastArrowRightSvg from "iconoir/icons/fast-arrow-right.svg?raw" +import flashSvg from "iconoir/icons/flash.svg?raw" +import flaskSvg from "iconoir/icons/flask.svg?raw" +import folderSvg from "iconoir/icons/folder.svg?raw" +import folderPlusSvg from "iconoir/icons/folder-plus.svg?raw" +import gitBranchSvg from "iconoir/icons/git-branch.svg?raw" +import githubSvg from "iconoir/icons/github.svg?raw" +import glassesSvg from "iconoir/icons/glasses.svg?raw" +import halfMoonSvg from "iconoir/icons/half-moon.svg?raw" +import helpCircleSvg from "iconoir/icons/help-circle.svg?raw" +import homeSimpleSvg from "iconoir/icons/home-simple.svg?raw" +import keyCommandSvg from "iconoir/icons/key-command.svg?raw" +import layoutLeftSvg from "iconoir/icons/layout-left.svg?raw" +import layoutRightSvg from "iconoir/icons/layout-right.svg?raw" +import linkSvg from "iconoir/icons/link.svg?raw" +import listSvg from "iconoir/icons/list.svg?raw" +import macDockSvg from "iconoir/icons/mac-dock.svg?raw" +import mediaImageSvg from "iconoir/icons/media-image.svg?raw" +import menuScaleSvg from "iconoir/icons/menu-scale.svg?raw" +import messageSvg from "iconoir/icons/message.svg?raw" +import messageTextSvg from "iconoir/icons/message-text.svg?raw" +import microphoneSvg from "iconoir/icons/microphone.svg?raw" +import minusSvg from "iconoir/icons/minus.svg?raw" +import moreHorizSvg from "iconoir/icons/more-horiz.svg?raw" +import navArrowDownSvg from "iconoir/icons/nav-arrow-down.svg?raw" +import navArrowLeftSvg from "iconoir/icons/nav-arrow-left.svg?raw" +import navArrowRightSvg from "iconoir/icons/nav-arrow-right.svg?raw" +import networkSvg from "iconoir/icons/network.svg?raw" +import networkReverseSvg from "iconoir/icons/network-reverse.svg?raw" +import openBookSvg from "iconoir/icons/open-book.svg?raw" +import openNewWindowSvg from "iconoir/icons/open-new-window.svg?raw" +import pageSvg from "iconoir/icons/page.svg?raw" +import pinSvg from "iconoir/icons/pin.svg?raw" +import pinSolidSvg from "iconoir/icons/pin-solid.svg?raw" +import plusSvg from "iconoir/icons/plus.svg?raw" +import prohibitionSvg from "iconoir/icons/prohibition.svg?raw" +import refreshSvg from "iconoir/icons/refresh.svg?raw" +import reportsSvg from "iconoir/icons/reports.svg?raw" +import searchSvg from "iconoir/icons/search.svg?raw" +import searchEngineSvg from "iconoir/icons/search-engine.svg?raw" +import sendDiagonalSvg from "iconoir/icons/send-diagonal.svg?raw" +import serverSvg from "iconoir/icons/server.svg?raw" +import settingsSvg from "iconoir/icons/settings.svg?raw" +import settingsProfilesSvg from "iconoir/icons/settings-profiles.svg?raw" +import shareIosSvg from "iconoir/icons/share-ios.svg?raw" +import shieldSvg from "iconoir/icons/shield.svg?raw" +import shieldAlertSvg from "iconoir/icons/shield-alert.svg?raw" +import sidebarCollapseSvg from "iconoir/icons/sidebar-collapse.svg?raw" +import sidebarExpandSvg from "iconoir/icons/sidebar-expand.svg?raw" +import sparksSvg from "iconoir/icons/sparks.svg?raw" +import squareSvg from "iconoir/icons/square.svg?raw" +import squareCursorSvg from "iconoir/icons/square-cursor.svg?raw" +import starSvg from "iconoir/icons/star.svg?raw" +import starSolidSvg from "iconoir/icons/star-solid.svg?raw" +import sunLightSvg from "iconoir/icons/sun-light.svg?raw" +import tableSvg from "iconoir/icons/table.svg?raw" +import taskListSvg from "iconoir/icons/task-list.svg?raw" +import terminalSvg from "iconoir/icons/terminal.svg?raw" +import trashSvg from "iconoir/icons/trash.svg?raw" +import undoSvg from "iconoir/icons/undo.svg?raw" +import verticalSplitSvg from "iconoir/icons/vertical-split.svg?raw" +import viewGridSvg from "iconoir/icons/view-grid.svg?raw" +import warningCircleSvg from "iconoir/icons/warning-circle.svg?raw" +import xmarkSvg from "iconoir/icons/xmark.svg?raw" +import xmarkCircleSvg from "iconoir/icons/xmark-circle.svg?raw" + +const sources = { + activity: activitySvg, + "align-right": alignRightSvg, + archive: archiveSvg, + "arrow-left": arrowLeftSvg, + "arrow-right": arrowRightSvg, + "arrow-separate-vertical": arrowSeparateVerticalSvg, + "arrow-up": arrowUpSvg, + atom: atomSvg, + attachment: attachmentSvg, + brain: brainSvg, + "brain-electricity": brainElectricitySvg, + "brain-research": brainResearchSvg, + "chat-bubble-empty": chatBubbleEmptySvg, + check: checkSvg, + "check-circle": checkCircleSvg, + clock: clockSvg, + cloud: cloudSvg, + "cloud-upload": cloudUploadSvg, + code: codeSvg, + "code-brackets": codeBracketsSvg, + "code-brackets-square": codeBracketsSquareSvg, + collapse: collapseSvg, + copy: copySvg, + cpu: cpuSvg, + "dashboard-dots": dashboardDotsSvg, + database: databaseSvg, + "database-settings": databaseSettingsSvg, + discord: discordSvg, + download: downloadSvg, + "edit-pencil": editPencilSvg, + expand: expandSvg, + eye: eyeSvg, + "fast-arrow-right": fastArrowRightSvg, + flash: flashSvg, + flask: flaskSvg, + folder: folderSvg, + "folder-plus": folderPlusSvg, + "git-branch": gitBranchSvg, + github: githubSvg, + glasses: glassesSvg, + "half-moon": halfMoonSvg, + "help-circle": helpCircleSvg, + "home-simple": homeSimpleSvg, + "key-command": keyCommandSvg, + "layout-left": layoutLeftSvg, + "layout-right": layoutRightSvg, + link: linkSvg, + list: listSvg, + "mac-dock": macDockSvg, + "media-image": mediaImageSvg, + "menu-scale": menuScaleSvg, + message: messageSvg, + "message-text": messageTextSvg, + microphone: microphoneSvg, + minus: minusSvg, + "more-horiz": moreHorizSvg, + "nav-arrow-down": navArrowDownSvg, + "nav-arrow-left": navArrowLeftSvg, + "nav-arrow-right": navArrowRightSvg, + network: networkSvg, + "network-reverse": networkReverseSvg, + "open-book": openBookSvg, + "open-new-window": openNewWindowSvg, + page: pageSvg, + pin: pinSvg, + "pin-solid": pinSolidSvg, + plus: plusSvg, + prohibition: prohibitionSvg, + refresh: refreshSvg, + reports: reportsSvg, + search: searchSvg, + "search-engine": searchEngineSvg, + "send-diagonal": sendDiagonalSvg, + server: serverSvg, + settings: settingsSvg, + "settings-profiles": settingsProfilesSvg, + "share-ios": shareIosSvg, + shield: shieldSvg, + "shield-alert": shieldAlertSvg, + "sidebar-collapse": sidebarCollapseSvg, + "sidebar-expand": sidebarExpandSvg, + sparks: sparksSvg, + square: squareSvg, + "square-cursor": squareCursorSvg, + star: starSvg, + "star-solid": starSolidSvg, + "sun-light": sunLightSvg, + table: tableSvg, + "task-list": taskListSvg, + terminal: terminalSvg, + trash: trashSvg, + undo: undoSvg, + "vertical-split": verticalSplitSvg, + "view-grid": viewGridSvg, + "warning-circle": warningCircleSvg, + xmark: xmarkSvg, + "xmark-circle": xmarkCircleSvg, +} as const + +type SourceName = keyof typeof sources +type IconVariant = "regular" | "solid" + +const regular = (source: T) => ({ source, variant: "regular" as const }) +const solid = (source: T) => ({ source, variant: "solid" as const }) + +// Public names stay stable for consumers while every semantic role resolves to +// a named Iconoir glyph. Aliases are limited to true visual synonyms or state +// variants; unrelated workspace concepts no longer share placeholder artwork. +export const iconSpecs = { + activity: regular("activity"), + "alert-circle": regular("warning-circle"), + "align-right": regular("align-right"), + archive: regular("archive"), + "arrow-down-to-line": regular("download"), + "arrow-left": regular("arrow-left"), + "arrow-right": regular("arrow-right"), + "arrow-up": regular("arrow-up"), + artifact: regular("reports"), + atom: regular("atom"), + bolt: regular("flash"), + "book-open": regular("open-book"), + braces: regular("code-brackets"), + brain: regular("brain"), + branch: regular("git-branch"), + "bubble-5": regular("chat-bubble-empty"), + "bullet-list": regular("list"), + check: regular("check"), + "check-small": regular("check"), + checklist: regular("task-list"), + "chevron-double-right": regular("fast-arrow-right"), + "chevron-down": regular("nav-arrow-down"), + "chevron-grabber-vertical": regular("arrow-separate-vertical"), + "chevron-left": regular("nav-arrow-left"), + "chevron-right": regular("nav-arrow-right"), + "circle-ban-sign": regular("prohibition"), + "circle-check": regular("check-circle"), + "circle-x": regular("xmark-circle"), + clock: regular("clock"), + close: regular("xmark"), + "close-small": regular("xmark"), + cloud: regular("cloud"), + "cloud-upload": regular("cloud-upload"), + code: regular("code-brackets-square"), + "code-lines": regular("code"), + collapse: regular("collapse"), + comment: regular("message-text"), + console: regular("terminal"), + copy: regular("copy"), + cpu: regular("cpu"), + dash: regular("minus"), + database: regular("database"), + discord: regular("discord"), + "dot-grid": regular("dashboard-dots"), + download: regular("download"), + edit: regular("edit-pencil"), + "edit-small-2": regular("edit-pencil"), + enter: regular("send-diagonal"), + expand: regular("expand"), + eye: regular("eye"), + file: regular("page"), + flask: regular("flask"), + folder: regular("folder"), + "folder-add-left": regular("folder-plus"), + "folder-tree": regular("network-reverse"), + github: regular("github"), + glasses: regular("glasses"), + help: regular("help-circle"), + home: regular("home-simple"), + keyboard: regular("key-command"), + "layout-bottom": regular("mac-dock"), + "layout-bottom-full": regular("mac-dock"), + "layout-bottom-partial": regular("mac-dock"), + "layout-grid": regular("view-grid"), + "layout-left": regular("layout-left"), + "layout-left-full": regular("sidebar-expand"), + "layout-left-partial": regular("sidebar-collapse"), + "layout-right": regular("layout-right"), + "layout-right-full": regular("layout-right"), + "layout-right-partial": regular("layout-right"), + link: regular("link"), + "magnifying-glass": regular("search"), + "magnifying-glass-menu": regular("search-engine"), + mcp: regular("network"), + menu: regular("menu-scale"), + microphone: regular("microphone"), + models: regular("brain-electricity"), + moon: regular("half-moon"), + "more-horizontal": regular("more-horiz"), + network: regular("network"), + paperclip: regular("attachment"), + "pencil-line": regular("edit-pencil"), + photo: regular("media-image"), + pin: regular("pin"), + "pin-filled": solid("pin-solid"), + plus: regular("plus"), + "plus-small": regular("plus"), + providers: regular("database-settings"), + refresh: regular("refresh"), + research: regular("brain-research"), + selector: regular("arrow-separate-vertical"), + server: regular("server"), + "settings-gear": regular("settings"), + share: regular("share-ios"), + shield: regular("shield"), + "shield-alert": regular("shield-alert"), + sliders: regular("settings-profiles"), + sparkles: regular("sparks"), + "speech-bubble": regular("message"), + split: regular("vertical-split"), + "square-arrow-top-right": regular("open-new-window"), + star: regular("star"), + "star-filled": solid("star-solid"), + stop: solid("square"), + sun: regular("sun-light"), + table: regular("table"), + task: regular("task-list"), + trash: regular("trash"), + undo: regular("undo"), + "window-cursor": regular("square-cursor"), +} as const + +export type IconName = keyof typeof iconSpecs + +export interface IconDefinition { + body: string + source: SourceName + variant: IconVariant +} + +const body = (svg: string) => { + const start = svg.indexOf(">") + const end = svg.lastIndexOf("") + if (start < 0 || end < 0 || end <= start) throw new Error("Invalid bundled Iconoir SVG") + return svg.slice(start + 1, end).trim() +} + +export const iconDefinitions = Object.fromEntries( + Object.entries(iconSpecs).map(([name, spec]) => [ + name, + { + body: body(sources[spec.source]), + source: spec.source, + variant: spec.variant, + }, + ]), +) as Record diff --git a/frontend/ui/src/components/keybind.css b/frontend/ui/src/components/keybind.css index 1a9e5dce..f34d1e20 100644 --- a/frontend/ui/src/components/keybind.css +++ b/frontend/ui/src/components/keybind.css @@ -5,7 +5,7 @@ flex-shrink: 0; height: 20px; padding: 0 8px; - border-radius: 2px; + border-radius: var(--radius-xs); background: var(--surface-base); box-shadow: var(--shadow-xxs-border); diff --git a/frontend/ui/src/components/line-comment.css b/frontend/ui/src/components/line-comment.css index 9dc8eb74..87cace00 100644 --- a/frontend/ui/src/components/line-comment.css +++ b/frontend/ui/src/components/line-comment.css @@ -40,7 +40,7 @@ z-index: var(--line-comment-popover-z, 40); min-width: 200px; max-width: min(320px, calc(100vw - 48px)); - border-radius: 8px; + border-radius: var(--radius-md); background: var(--surface-raised-stronger-non-alpha); box-shadow: var(--shadow-lg-border-base); padding: 12px; @@ -50,7 +50,7 @@ width: 380px; max-width: min(380px, calc(100vw - 48px)); padding: 8px; - border-radius: 14px; + border-radius: var(--radius-lg); } [data-component="line-comment"] [data-slot="line-comment-content"] { diff --git a/frontend/ui/src/components/markdown.css b/frontend/ui/src/components/markdown.css index 69dc5a09..411179ea 100644 --- a/frontend/ui/src/components/markdown.css +++ b/frontend/ui/src/components/markdown.css @@ -117,7 +117,7 @@ .shiki { font-size: 13px; padding: 8px 12px; - border-radius: 4px; + border-radius: var(--radius-md); border: 0.5px solid var(--border-weak-base); } @@ -170,7 +170,7 @@ /* padding: 2px 2px; */ /* margin: 0 1.5px; */ - /* border-radius: 2px; */ + /* border-radius: var(--radius-xs); */ /* background: var(--surface-base); */ /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ } @@ -202,7 +202,7 @@ img { max-width: 100%; height: auto; - border-radius: 4px; + border-radius: var(--radius-md); margin: 1.5rem 0; display: block; } diff --git a/frontend/ui/src/components/markdown.tsx b/frontend/ui/src/components/markdown.tsx index 66ad23d1..9427866b 100644 --- a/frontend/ui/src/components/markdown.tsx +++ b/frontend/ui/src/components/markdown.tsx @@ -56,6 +56,17 @@ export function sanitize(html: string) { return DOMPurify.sanitize(html, config) } +export function markdownFallback(markdown: string) { + const escaped = markdown.replace(/[&<>"']/g, (value) => { + if (value === "&") return "&" + if (value === "<") return "<" + if (value === ">") return ">" + if (value === '"') return """ + return "'" + }) + return `

${escaped.replace(/\r?\n/g, "
")}

` +} + type Resolve = (src: string) => string const images = createContext() @@ -228,8 +239,10 @@ export function Markdown( } } - const next = await marked.parse(markdown) - const safe = sanitize(next) + const safe = await marked.parse(markdown).then( + (next) => sanitize(next), + () => markdownFallback(markdown), + ) if (key && hash) touch(key, { hash, html: safe }) return safe }, diff --git a/frontend/ui/src/components/message-part-artifact.test.ts b/frontend/ui/src/components/message-part-artifact.test.ts index 045fa94b..531ec26f 100644 --- a/frontend/ui/src/components/message-part-artifact.test.ts +++ b/frontend/ui/src/components/message-part-artifact.test.ts @@ -17,7 +17,10 @@ test("saved workspace artifacts render previewable, openable results", () => { expect(part).toContain("sha256 {artifact().sha256.slice(0, 12)}") expect(part).toContain('data-slot="saved-artifact-preview"') expect(part).toContain('data-slot="saved-artifact-preview-text"') - expect(part).toContain("data.openFile?.(artifact().path)") + expect(artifact).toContain("const artifact = saved()") + expect(artifact).toContain("data.openArtifact(artifact.id)") + expect(artifact).toContain("data.openFile?.(artifact.path)") + expect(artifact).toContain("onClick={open}") expect(part).toContain("Open beside chat") expect(part).toContain("Show save receipt") expect(artifact).not.toContain("defaultOpen") diff --git a/frontend/ui/src/components/message-part-notebook.test.ts b/frontend/ui/src/components/message-part-notebook.test.ts index ae8f6218..d071e645 100644 --- a/frontend/ui/src/components/message-part-notebook.test.ts +++ b/frontend/ui/src/components/message-part-notebook.test.ts @@ -17,13 +17,19 @@ test("notebook tools keep complete source, output, and figures behind a compact expect(part).toContain('data-slot="kernel-tool-source"') expect(part).toContain("{code()}") expect(part).toContain('typeof props.input.kernel === "string"') - expect(part).toContain("env {kernel()}") + expect(kernel).toContain("`env ${kernel()}`") + expect(kernel).toContain("`cell ${count()}`") + expect(kernel).toContain("{subtitle()}") expect(part).toContain("Show output") expect(part).toContain('data-slot="kernel-tool-output" open') expect(part).toContain('data-slot="kernel-tool-images"') expect(part).toContain('props.input.action === "stop"') expect(part).toContain('trigger={{ title: "Kernel stopped"') - expect(part).toContain('title: props.status === "completed" ? "Computed" : "Computing"') + expect(kernel).toContain("scienceTaskLabel") + expect(kernel).toContain('props.metadata.ok === false || props.status === "error"') + expect(kernel).toContain("`Failed · ${task()}`") + expect(kernel).toContain('props.status === "completed" ? task()') + expect(kernel).toContain("`Running · ${task()}`") expect(kernel).not.toContain("defaultOpen") expect(styles()).toContain("max-height: calc(5 * 1.55em + 20px)") expect(styles()).toContain("overflow: auto") diff --git a/frontend/ui/src/components/message-part.css b/frontend/ui/src/components/message-part.css index 6bf20445..57e8a81d 100644 --- a/frontend/ui/src/components/message-part.css +++ b/frontend/ui/src/components/message-part.css @@ -8,6 +8,8 @@ } [data-component="user-message"] { + width: 100%; + min-width: 0; font-family: var(--font-family-sans); font-size: var(--font-size-base); font-style: normal; @@ -17,52 +19,70 @@ color: var(--text-base); display: flex; flex-direction: column; + align-items: flex-end; gap: 8px; [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; + justify-content: flex-end; gap: 8px; } - [data-slot="user-message-attachment"] { + [data-slot="user-message-row"] { + width: 100%; + min-width: 0; display: flex; - flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 6px; + } + + [data-slot="user-message-attachment"] { + width: min(240px, 100%); + min-width: 156px; + height: 52px; + display: grid; + grid-template-columns: 40px minmax(0, 1fr); align-items: center; - justify-content: center; - border-radius: 6px; + gap: 8px; + padding: 5px 9px 5px 5px; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-sm); overflow: hidden; background: var(--surface-weak); - border: 1px solid var(--border-weak-base); - transition: border-color 0.15s ease; + color: inherit; + text-decoration: none; + transition: + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard); &:hover { border-color: var(--border-strong-base); + background: var(--surface-raised-base-hover); } - &[data-type="image"] { - width: 48px; - height: 48px; - } - - &[data-type="file"] { - width: 48px; - height: 48px; + &:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; } } [data-slot="user-message-attachment-image"] { - width: 100%; - height: 100%; + width: 40px; + height: 40px; + border-radius: var(--radius-xs); object-fit: cover; } [data-slot="user-message-attachment-icon"] { - width: 100%; - height: 100%; + width: 40px; + height: 40px; display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-xs); + background: var(--surface-base); color: var(--icon-weak); [data-component="icon"] { @@ -71,15 +91,44 @@ } } + [data-slot="user-message-attachment-copy"] { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + + strong, + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--text-strong); + font-size: 12.5px; + font-weight: var(--font-weight-medium); + line-height: 16px; + } + + span { + color: var(--text-weak); + font-size: 11.5px; + font-weight: var(--font-weight-regular); + line-height: 15px; + } + } + [data-slot="user-message-text"] { position: relative; + min-width: 0; white-space: pre-wrap; word-break: break-word; overflow: hidden; background: var(--surface-weak); border: 1px solid var(--border-weak-base); padding: 8px 12px; - border-radius: 4px; + border-radius: var(--radius-xs); [data-highlight="file"] { color: var(--syntax-property); @@ -88,18 +137,26 @@ [data-highlight="agent"] { color: var(--syntax-type); } + } - [data-slot="user-message-copy-wrapper"] { - position: absolute; - top: 7px; - right: 7px; - opacity: 0; - transition: opacity 0.15s ease; - } + [data-slot="user-message-copy-wrapper"] { + flex: 0 0 auto; + margin-top: 1px; + opacity: 0; + color: var(--text-weak); + transition: + opacity var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); + } - &:hover [data-slot="user-message-copy-wrapper"] { - opacity: 1; - } + [data-slot="user-message-row"]:hover [data-slot="user-message-copy-wrapper"], + [data-slot="user-message-row"]:focus-within [data-slot="user-message-copy-wrapper"], + [data-slot="user-message-copy-wrapper"][data-copied="true"] { + opacity: 1; + } + + [data-slot="user-message-copy-wrapper"][data-copied="true"] [data-slot="icon-svg"] { + color: var(--icon-success-base, var(--text-strong)); } .text-text-strong { @@ -111,6 +168,12 @@ } } +@media (pointer: coarse) { + [data-component="user-message"] [data-slot="user-message-copy-wrapper"] { + opacity: 1; + } +} + [data-component="text-part"] { width: 100%; @@ -319,10 +382,6 @@ color: var(--text-base); } - [data-slot="message-part-title-text"] { - text-transform: capitalize; - } - [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ } @@ -488,8 +547,7 @@ [data-slot="diagnostic-label"] { color: var(--text-on-critical-base); font-weight: var(--font-weight-medium); - text-transform: uppercase; - letter-spacing: -0.5px; + letter-spacing: var(--letter-spacing-normal); flex-shrink: 0; } @@ -532,7 +590,7 @@ top: calc(2px + var(--sticky-header-height, 40px)); bottom: 0px; z-index: 20; - border-radius: 6px; + border-radius: var(--radius-xs); border: none; box-shadow: var(--shadow-xs-border-base); background-color: var(--surface-raised-base); @@ -540,14 +598,14 @@ overflow-anchor: none; & > *:first-child { - border-top-left-radius: 6px; - border-top-right-radius: 6px; + border-top-left-radius: var(--radius-xs); + border-top-right-radius: var(--radius-xs); overflow: hidden; } & > *:last-child { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; + border-bottom-left-radius: var(--radius-xs); + border-bottom-right-radius: var(--radius-xs); overflow: hidden; } @@ -566,7 +624,7 @@ position: absolute; inset: -1.5px; top: -5px; - border-radius: 7.5px; + border-radius: var(--radius-sm); border: 1.5px solid transparent; background: linear-gradient(var(--background-base) 0 0) padding-box, @@ -670,7 +728,7 @@ width: 100%; height: auto; border: 1px solid var(--border-weak-base); - border-radius: 6px; + border-radius: var(--radius-xs); } } } @@ -694,7 +752,7 @@ overflow: hidden; color: var(--text-strong); font-size: 13px; - font-weight: 500; + font-weight: var(--font-weight-medium); text-overflow: ellipsis; white-space: nowrap; } @@ -719,7 +777,7 @@ max-height: 420px; object-fit: contain; border: 1px solid var(--border-weak-base); - border-radius: 6px; + border-radius: var(--radius-xs); background: var(--background-base); } @@ -728,7 +786,7 @@ overflow: auto; padding: 10px 12px; border: 1px solid var(--border-weak-base); - border-radius: 6px; + border-radius: var(--radius-xs); background: var(--background-base); } @@ -790,7 +848,7 @@ gap: 6px; padding: 8px 12px; background-color: var(--surface-raised-strong); - border-radius: 0 0 6px 6px; + border-radius: 0 0 var(--radius-xs) var(--radius-xs); [data-slot="permission-summary"] { font-size: 12px; @@ -812,7 +870,7 @@ flex-direction: column; padding: 12px; background-color: var(--surface-inset-base); - border-radius: 0 0 6px 6px; + border-radius: 0 0 var(--radius-xs) var(--radius-xs); gap: 12px; [data-slot="question-tabs"] { @@ -823,7 +881,7 @@ [data-slot="question-tab"] { padding: 4px 12px; font-size: 13px; - border-radius: 4px; + border-radius: var(--radius-xs); background-color: var(--surface-base); color: var(--text-base); border: none; @@ -871,7 +929,7 @@ padding: 8px 12px; background-color: var(--surface-base); border: 1px solid var(--border-weaker-base); - border-radius: 6px; + border-radius: var(--radius-xs); cursor: pointer; text-align: left; width: 100%; @@ -898,7 +956,7 @@ [data-slot="option-label"] { font-size: 14px; color: var(--text-base); - font-weight: 500; + font-weight: var(--font-weight-medium); } [data-slot="option-description"] { @@ -918,7 +976,7 @@ padding: 8px 12px; font-size: 14px; border: 1px solid var(--border-default); - border-radius: 6px; + border-radius: var(--radius-xs); background-color: var(--surface-base); color: var(--text-base); outline: none; diff --git a/frontend/ui/src/components/message-part.tsx b/frontend/ui/src/components/message-part.tsx index a8042cd1..3e998470 100644 --- a/frontend/ui/src/components/message-part.tsx +++ b/frontend/ui/src/components/message-part.tsx @@ -52,7 +52,7 @@ import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" import { NotebookView, type NotebookCellProps } from "./notebook-cell" -import { savedArtifact, scienceTaskLabel, skillName, stripRedactedReasoning } from "./tool-display" +import { savedArtifact, scienceTaskLabel, sentenceCaseLabel, skillName, stripRedactedReasoning } from "./tool-display" import { ToolRegistry, type ToolProps } from "./tool-registry" export { ARTIFACT_TOOL, ToolRegistry, type ToolComponent, type ToolProps } from "./tool-registry" @@ -293,7 +293,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "task": return { icon: "task", - title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), + title: i18n.t("ui.tool.agent", { type: sentenceCaseLabel(String(input.subagent_type || "task")) }), subtitle: input.description, } case "bash": @@ -473,6 +473,21 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp dialog.show(() => ) } + const attachmentFormat = (file: FilePart) => { + const name = file.filename?.trim() ?? "" + const dot = name.lastIndexOf(".") + const extension = + dot > -1 + ? name + .slice(dot + 1) + .trim() + .toUpperCase() + : "" + if (extension && extension.length <= 8) return extension + if (file.mime === "application/pdf") return "PDF" + return file.mime.split("/").pop()?.replace(/^x-/, "").toUpperCase() || "FILE" + } + const handleCopy = async () => { const content = text() if (!content) return @@ -492,11 +507,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
{(file) => ( -
{ + href={file.url} + target="_blank" + rel="noreferrer" + aria-label={`${file.mime.startsWith("image/") ? "Preview" : "Open"} ${file.filename ?? i18n.t("ui.message.attachment.alt")}`} + onClick={(event) => { if (file.mime.startsWith("image/") && file.url) { + event.preventDefault() openImagePreview(file.url, file.filename) } }} @@ -505,44 +525,35 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp when={file.mime.startsWith("image/") && file.url} fallback={
- +
} > - {file.filename + -
+ + {file.filename ?? i18n.t("ui.message.attachment.alt")} + + {attachmentFormat(file)} · {file.mime.startsWith("image/") ? "Preview" : "Open"} + + + )}
-
(textRef = el)} onClick={toggleExpanded}> - - -
+
+
e.preventDefault()} onClick={(event) => { event.stopPropagation() @@ -552,6 +563,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp />
+
(textRef = el)} onClick={toggleExpanded}> + + +
@@ -1300,8 +1325,9 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), - titleClass: "capitalize", + title: i18n.t("ui.tool.agent", { + type: sentenceCaseLabel(String(props.input.subagent_type || props.tool)), + }), subtitle: props.input.description, }} onSubtitleClick={handleSubtitleClick} @@ -1323,8 +1349,9 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), - titleClass: "capitalize", + title: i18n.t("ui.tool.agent", { + type: sentenceCaseLabel(String(props.input.subagent_type || props.tool)), + }), subtitle: props.input.description, }} onSubtitleClick={handleSubtitleClick} @@ -1342,8 +1369,9 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), - titleClass: "capitalize", + title: i18n.t("ui.tool.agent", { + type: sentenceCaseLabel(String(props.input.subagent_type || props.tool)), + }), subtitle: props.input.description, }} onSubtitleClick={handleSubtitleClick} diff --git a/frontend/ui/src/components/notebook-cell.css b/frontend/ui/src/components/notebook-cell.css index 274e8fc7..dcc1eb0d 100644 --- a/frontend/ui/src/components/notebook-cell.css +++ b/frontend/ui/src/components/notebook-cell.css @@ -9,7 +9,7 @@ padding: 8px 12px; font-family: var(--font-family-sans); font-size: var(--font-size-small); - font-weight: 600; + font-weight: var(--font-weight-medium); color: var(--text-base); background: var(--bg-surface); border-bottom: 1px solid var(--border-weaker-base); @@ -125,7 +125,7 @@ color: var(--text-error); [data-slot="notebook-error-name"] { - font-weight: 600; + font-weight: var(--font-weight-medium); padding: 4px 0; } diff --git a/frontend/ui/src/components/popover.css b/frontend/ui/src/components/popover.css index b49542af..c27139be 100644 --- a/frontend/ui/src/components/popover.css +++ b/frontend/ui/src/components/popover.css @@ -20,11 +20,12 @@ } &[data-closed] { - animation: popover-close 0.15s ease-out; + pointer-events: none; + animation: popover-close var(--duration-fast) ease-in forwards; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + animation: popover-open var(--duration-slow) var(--ease-out-expo); } [data-slot="popover-header"] { diff --git a/frontend/ui/src/components/select.css b/frontend/ui/src/components/select.css index 25dd2eb4..64d51f59 100644 --- a/frontend/ui/src/components/select.css +++ b/frontend/ui/src/components/select.css @@ -16,7 +16,7 @@ justify-content: center; flex-shrink: 0; color: var(--text-weak); - transition: transform 0.1s ease-in-out; + transition: transform var(--duration-fast) var(--ease-standard); } &[data-expanded] { @@ -48,7 +48,7 @@ [data-slot="select-select-trigger"] { padding: 6px 6px 6px 12px; box-shadow: none; - border-radius: 6px; + border-radius: var(--radius-xs); min-width: 160px; height: 32px; justify-content: flex-end; @@ -71,8 +71,8 @@ flex-shrink: 0; color: var(--text-weak); background-color: var(--surface-raised-base); - border-radius: 4px; - transition: transform 0.1s ease-in-out; + border-radius: var(--radius-xs); + transition: transform var(--duration-fast) var(--ease-standard); } &[data-slot="select-select-trigger"]:hover:not(:disabled), @@ -100,8 +100,9 @@ box-shadow: var(--shadow-xs-border); z-index: 60; - &[data-expanded] { - animation: select-open 0.15s ease-out; + &[data-closed] { + pointer-events: none; + animation: select-close var(--duration-fast) ease-in forwards; } [data-slot="select-select-content-list"] { @@ -124,10 +125,11 @@ [data-slot="select-select-item"] { position: relative; display: flex; + min-height: 32px; align-items: center; padding: 2px 8px; gap: 12px; - border-radius: 4px; + border-radius: var(--radius-xs); cursor: default; /* text-12-medium */ @@ -140,9 +142,6 @@ color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; outline: none; user-select: none; @@ -172,7 +171,7 @@ [data-component="select-content"][data-trigger-style="settings"] { min-width: 160px; - border-radius: 8px; + border-radius: var(--radius-md); padding: 0; [data-slot="select-select-content-list"] { @@ -190,13 +189,19 @@ } } -@keyframes select-open { - from { - opacity: 0; - transform: scale(0.95); +@media (pointer: coarse) { + [data-component="select-content"] [data-slot="select-select-item"] { + min-height: 44px; } - to { +} + +@keyframes select-close { + from { opacity: 1; transform: scale(1); } + to { + opacity: 0; + transform: scale(0.96); + } } diff --git a/frontend/ui/src/components/session-review.css b/frontend/ui/src/components/session-review.css index 20d2fef1..83d9c82f 100644 --- a/frontend/ui/src/components/session-review.css +++ b/frontend/ui/src/components/session-review.css @@ -128,7 +128,7 @@ background: transparent; color: var(--text-base); cursor: pointer; - border-radius: 4px; + border-radius: var(--radius-xs); opacity: 0; transition: opacity 0.15s ease; @@ -179,7 +179,7 @@ max-width: 100%; max-height: 60vh; object-fit: contain; - border-radius: 8px; + border-radius: var(--radius-md); border: 1px solid var(--border-weak-base); background: var(--background-base); } diff --git a/frontend/ui/src/components/session-turn.css b/frontend/ui/src/components/session-turn.css index f01e5adf..7df09b45 100644 --- a/frontend/ui/src/components/session-turn.css +++ b/frontend/ui/src/components/session-turn.css @@ -35,7 +35,7 @@ display: inline-flex; align-items: center; padding: 2px 6px; - border-radius: 4px; + border-radius: var(--radius-xs); font-family: var(--font-family-mono); font-size: var(--font-size-x-small); font-weight: var(--font-weight-medium); @@ -124,8 +124,8 @@ height: 8px; bottom: 0px; background: - linear-gradient(to bottom, transparent, var(--surface-weak)), - linear-gradient(to bottom, transparent, var(--surface-weak)); + linear-gradient(to bottom, transparent, var(--user-message-surface, var(--surface-weak))), + linear-gradient(to bottom, transparent, var(--user-message-surface, var(--surface-weak))); pointer-events: none; } @@ -149,13 +149,13 @@ height: 22px; width: 22px; border: none; - border-radius: 6px; + border-radius: var(--radius-xs); background: transparent; cursor: pointer; color: var(--text-weak); [data-slot="icon-svg"] { - transition: transform 0.15s ease; + transition: transform var(--duration-fast) var(--ease-standard); } } @@ -181,7 +181,7 @@ [data-slot="session-turn-message-title"] { width: 100%; font-size: var(--font-size-large); - font-weight: 500; + font-weight: var(--font-weight-emphasis); color: var(--text-strong); overflow: hidden; text-overflow: ellipsis; @@ -248,7 +248,7 @@ [data-slot="session-turn-summary-title"] { font-size: 13px; /* text-12-medium */ - font-weight: 500; + font-weight: var(--font-weight-medium); color: var(--text-weak); } @@ -505,7 +505,7 @@ cursor: pointer; padding: 6px 10px; margin: -6px -10px; - border-radius: 8px; + border-radius: var(--radius-xs); border: 1px solid transparent; transition: background 0.12s ease, @@ -544,7 +544,7 @@ } [data-slot="session-turn-retry-message"] { - font-weight: 500; + font-weight: var(--font-weight-medium); color: var(--syntax-critical); } @@ -564,7 +564,7 @@ [data-slot="session-turn-details-text"] { font-size: 13px; /* text-12-medium */ - font-weight: 500; + font-weight: var(--font-weight-medium); } .error-card { @@ -620,12 +620,11 @@ color: var(--text-weak); font-size: 11px; line-height: 16px; - text-transform: uppercase; - letter-spacing: 0.035em; + letter-spacing: var(--letter-spacing-normal); strong { color: var(--text-base); - font-weight: 500; + font-weight: var(--font-weight-medium); } } } @@ -648,7 +647,7 @@ padding: 0; overflow: hidden; border: 1px solid var(--border-weak-base); - border-radius: 8px; + border-radius: var(--radius-md); background: var(--background-base); color: var(--text-base); font: inherit; @@ -697,13 +696,12 @@ strong { font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); } small { color: var(--text-weak); font-size: 10px; - text-transform: capitalize; } } diff --git a/frontend/ui/src/components/session-turn.tsx b/frontend/ui/src/components/session-turn.tsx index fe56b5d6..a5f8d289 100644 --- a/frontend/ui/src/components/session-turn.tsx +++ b/frontend/ui/src/components/session-turn.tsx @@ -19,7 +19,13 @@ import { Binary } from "@synsci/util/binary" import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Message, Part } from "./message-part" -import { artifactActions, generatedArtifacts, stripRedactedReasoning, writtenFiles } from "./tool-display" +import { + artifactActions, + generatedArtifacts, + sentenceCaseLabel, + stripRedactedReasoning, + writtenFiles, +} from "./tool-display" import { Markdown } from "./markdown" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" @@ -879,7 +885,7 @@ export function SessionTurn( {artifact.title} - {artifact.kind} + {sentenceCaseLabel(artifact.kind)} )} diff --git a/frontend/ui/src/components/switch.css b/frontend/ui/src/components/switch.css index 89e84473..8c24d6c1 100644 --- a/frontend/ui/src/components/switch.css +++ b/frontend/ui/src/components/switch.css @@ -1,6 +1,7 @@ [data-component="switch"] { position: relative; display: flex; + min-height: 32px; align-items: center; gap: 8px; cursor: default; @@ -23,7 +24,7 @@ width: 28px; height: 16px; flex-shrink: 0; - border-radius: 3px; + border-radius: var(--radius-xs); border: 1px solid var(--border-weak-base); background: var(--surface-base); transition: @@ -36,7 +37,7 @@ height: 14px; box-sizing: content-box; - border-radius: 2px; + border-radius: var(--radius-xs); border: 1px solid var(--border-base); background: var(--icon-invert-base); @@ -130,3 +131,10 @@ pointer-events: none; } } + +@media (pointer: coarse) { + [data-component="switch"] { + min-width: 44px; + min-height: 44px; + } +} diff --git a/frontend/ui/src/components/tabs.css b/frontend/ui/src/components/tabs.css index 56c3e083..4f647da6 100644 --- a/frontend/ui/src/components/tabs.css +++ b/frontend/ui/src/components/tabs.css @@ -278,7 +278,7 @@ [data-slot="tabs-trigger-wrapper"] { height: 26px; - border-radius: 6px; + border-radius: var(--radius-xs); color: var(--text-weak); &:not(:has([data-selected])):hover:not(:disabled) { @@ -314,7 +314,7 @@ width: 100%; height: 32px; border: none; - border-radius: 8px; + border-radius: var(--radius-xs); background-color: transparent; [data-slot="tabs-trigger"] { @@ -353,7 +353,7 @@ [data-slot="tabs-trigger-wrapper"] { height: 32px; border: none; - border-radius: 8px; + border-radius: var(--radius-xs); [data-slot="tabs-trigger"] { border: none; diff --git a/frontend/ui/src/components/text-field.css b/frontend/ui/src/components/text-field.css index e08513bf..f328f69d 100644 --- a/frontend/ui/src/components/text-field.css +++ b/frontend/ui/src/components/text-field.css @@ -133,3 +133,9 @@ } } } + +@media (pointer: coarse) { + [data-component="input"] [data-slot="input-input"] { + min-height: 44px; + } +} diff --git a/frontend/ui/src/components/toast.css b/frontend/ui/src/components/toast.css index 1459bb18..4f90b406 100644 --- a/frontend/ui/src/components/toast.css +++ b/frontend/ui/src/components/toast.css @@ -26,7 +26,7 @@ gap: 20px; padding: 16px 20px; pointer-events: auto; - transition: all 150ms ease-out; + transition: transform var(--duration-slow) var(--ease-out-expo); border-radius: var(--radius-lg); border: 1px solid var(--border-weak-base); @@ -41,11 +41,12 @@ } &[data-opened] { - animation: toastPopIn 150ms ease-out; + animation: toastPopIn var(--duration-slow) var(--ease-out-expo); } &[data-closed] { - animation: toastPopOut 100ms ease-in forwards; + pointer-events: none; + animation: toastPopOut var(--duration-fast) ease-in forwards; } &[data-swipe="move"] { @@ -54,11 +55,11 @@ &[data-swipe="cancel"] { transform: translateX(0); - transition: transform 200ms ease-out; + transition: transform var(--duration-slow) var(--ease-out-expo); } &[data-swipe="end"] { - animation: toastSwipeOut 100ms ease-out forwards; + animation: toastSwipeOut var(--duration-fast) ease-in forwards; } /* &[data-variant="success"] { */ @@ -169,7 +170,7 @@ height: 100%; width: var(--kb-toast-progress-fill-width); background-color: var(--color-primary); - transition: width 250ms linear; + transition: width var(--duration-slow) linear; } } diff --git a/frontend/ui/src/components/tool-display.test.ts b/frontend/ui/src/components/tool-display.test.ts index d6a409ce..760ee4b6 100644 --- a/frontend/ui/src/components/tool-display.test.ts +++ b/frontend/ui/src/components/tool-display.test.ts @@ -3,6 +3,7 @@ import { artifactActions, generatedArtifacts, humanizeToolName, + sentenceCaseLabel, savedArtifact, scienceTaskLabel, skillName, @@ -20,6 +21,18 @@ describe("humanizeToolName", () => { }) }) +describe("sentenceCaseLabel", () => { + test("normalizes interface identifiers without relying on CSS casing", () => { + expect(sentenceCaseLabel("general")).toBe("General") + expect(sentenceCaseLabel("code_review")).toBe("Code review") + expect(sentenceCaseLabel(" research-agent ")).toBe("Research agent") + }) + + test("preserves technical acronyms", () => { + expect(sentenceCaseLabel("PDF")).toBe("PDF") + }) +}) + describe("skillName", () => { test("prefers metadata.name", () => { expect(skillName({ metadata: { name: "deep-research" }, input: { name: "x" } })).toBe("deep-research") diff --git a/frontend/ui/src/components/tool-display.ts b/frontend/ui/src/components/tool-display.ts index 2d18d986..46e913b8 100644 --- a/frontend/ui/src/components/tool-display.ts +++ b/frontend/ui/src/components/tool-display.ts @@ -5,6 +5,12 @@ const titlecase = (s: string) => .map((w) => w[0].toUpperCase() + w.slice(1)) .join(" ") +export function sentenceCaseLabel(value: string): string { + const label = value.replace(/[\s_-]+/g, " ").trim() + if (!label) return label + return label[0].toLocaleUpperCase() + label.slice(1) +} + // There's no reliable signal to distinguish a first-party multi-word tool id // (e.g. "science_list_dbs") from an MCP "namespace_tool" id, so titlecase both. export function humanizeToolName(tool: string): string { diff --git a/frontend/ui/src/components/user-message-layout.test.ts b/frontend/ui/src/components/user-message-layout.test.ts new file mode 100644 index 00000000..2baa173f --- /dev/null +++ b/frontend/ui/src/components/user-message-layout.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" + +const source = await Bun.file(new URL("./message-part.tsx", import.meta.url)).text() +const css = await Bun.file(new URL("./message-part.css", import.meta.url)).text() +const turnCss = await Bun.file(new URL("./session-turn.css", import.meta.url)).text() + +describe("user message layout", () => { + test("places the copy action before and outside the message bubble", () => { + const row = source.indexOf('data-slot="user-message-row"') + const copy = source.indexOf('data-slot="user-message-copy-wrapper"', row) + const text = source.indexOf('data-slot="user-message-text"', row) + + expect(row).toBeGreaterThan(-1) + expect(copy).toBeGreaterThan(row) + expect(text).toBeGreaterThan(copy) + expect(source.slice(copy, text)).toContain('variant="ghost"') + expect(source.slice(copy, text)).toContain('type="button"') + }) + + test("keeps copy discoverable by hover, keyboard focus, and touch", () => { + expect(css).toContain('[data-slot="user-message-row"]:hover [data-slot="user-message-copy-wrapper"]') + expect(css).toContain('[data-slot="user-message-row"]:focus-within [data-slot="user-message-copy-wrapper"]') + expect(css).toContain("@media (pointer: coarse)") + + const copyRule = css.match(/\[data-slot="user-message-copy-wrapper"\]\s*\{([^}]*)\}/s)?.[1] ?? "" + expect(copyRule).toContain("opacity: 0") + expect(copyRule).not.toContain("position: absolute") + }) + + test("uses the same surface token for the collapsed-message fade", () => { + expect(turnCss).toContain("var(--user-message-surface, var(--surface-weak))") + expect(turnCss).toContain("transition: transform var(--duration-fast) var(--ease-standard)") + }) + + test("keeps sent attachment filenames, formats, and open affordances visible", () => { + expect(source).toContain('data-slot="user-message-attachment-copy"') + expect(source).toContain("attachmentFormat(file)") + expect(source).toContain('target="_blank"') + expect(source).toContain('') + expect(css).toContain("grid-template-columns: 40px minmax(0, 1fr)") + expect(css).toContain("font-weight: var(--font-weight-regular)") + expect(css).toContain("font-weight: var(--font-weight-medium)") + }) +}) diff --git a/frontend/ui/src/context/dialog.tsx b/frontend/ui/src/context/dialog.tsx index 62d12cac..c7d88aac 100644 --- a/frontend/ui/src/context/dialog.tsx +++ b/frontend/ui/src/context/dialog.tsx @@ -5,7 +5,6 @@ import { createSignal, getOwner, onCleanup, - Show, type Owner, type ParentProps, runWithOwner, @@ -27,29 +26,10 @@ type Active = { export interface ShowOptions { onClose?: () => void - /** - * Lightweight, non-modal presentation: no backdrop overlay and no body - * scroll lock (so opening the dialog doesn't visibly reflow the page). - * The dialog content still mounts inside a portal and dismisses on its - * own controls — it just doesn't dim/lock the page behind it. - */ - lite?: boolean } const Context = createContext>() -const LiteContext = createContext(false) - -/** - * True when the surrounding dialog was opened in `lite` mode (no backdrop, - * no scroll lock, no Kobalte focus-trap). Used by `` to render its - * content as a plain `
` instead of `Kobalte.Content`, which would - * otherwise throw without a Kobalte root. - */ -export function useDialogLite(): boolean { - return useContext(LiteContext) -} - function init() { const [active, setActive] = createSignal() const timer = { current: undefined as ReturnType | undefined } @@ -96,7 +76,7 @@ function init() { onCleanup(() => window.removeEventListener("keydown", onKeyDown, true)) }) - const show = (element: DialogElement, owner: Owner, onClose?: () => void, options?: { lite?: boolean }) => { + const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { // Immediately dispose any existing dialog when showing a new one const current = active() if (current) { @@ -114,43 +94,11 @@ function init() { let dispose: (() => void) | undefined let setClosing: ((closing: boolean) => void) | undefined - const lite = options?.lite === true - const node = runWithOwner(owner, () => createRoot((d: () => void) => { dispose = d const [closing, setClosingSignal] = createSignal(false) setClosing = setClosingSignal - // Lite mode bypasses Kobalte entirely. Kobalte's modal Dialog mounts - // a Portal at , adds focus-trap attributes, and (even with - // modal={false}) momentarily reshuffles body siblings during mount, - // which read as a page "refresh" the instant the dialog appears. - // Rendering inside the existing dialog-stack with no portal removes - // every body-level side effect — the element just appears in place. - if (lite) { - return ( - - -
-
- {element()} -
-
-
-
- ) - } return ( void) | ShowOptions) { const base = ctx.active?.owner ?? owner const opts: ShowOptions = typeof optionsOrOnClose === "function" ? { onClose: optionsOrOnClose } : (optionsOrOnClose ?? {}) - ctx.show(element, base, opts.onClose, { lite: opts.lite }) + ctx.show(element, base, opts.onClose) }, close() { ctx.close() diff --git a/frontend/ui/src/context/marked-loading.test.ts b/frontend/ui/src/context/marked-loading.test.ts new file mode 100644 index 00000000..022d6741 --- /dev/null +++ b/frontend/ui/src/context/marked-loading.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { highlightSnippet, registerOpenScienceDiffTheme, retryable } from "./marked" +import { markdownFallback } from "../components/markdown" + +const source = await readFile(new URL("./marked.tsx", import.meta.url), "utf8") + +describe("markdown runtime loading", () => { + test("keeps syntax engines out of the application entry until markdown needs them", () => { + expect(source).not.toMatch(/^import\s+(?!type\b).*\sfrom\s+["']marked["']/m) + expect(source).not.toMatch(/^import\s+(?!type\b).*\sfrom\s+["']@pierre\/diffs["']/m) + expect(source).toContain('import("marked")') + expect(source).toContain('import("@pierre/diffs")') + }) + + test("registers the OpenScience theme before first-use highlighting", async () => { + const html = await highlightSnippet("const result = 42", "javascript") + + expect(html).toContain("result") + expect(html).toContain("var(--syntax-keyword)") + expect(html).toContain(" { + let registrations = 0 + const registerCustomTheme = () => registrations++ + + registerOpenScienceDiffTheme({ registerCustomTheme }) + registerOpenScienceDiffTheme({ registerCustomTheme }) + + expect(registrations).toBe(1) + }) + + test("retries a transient chunk failure instead of poisoning the app lifetime", async () => { + let attempts = 0 + const load = retryable(async () => { + attempts++ + if (attempts === 1) throw new Error("stale chunk") + return "loaded" + }) + + expect(load()).rejects.toThrow("stale chunk") + expect(await load()).toBe("loaded") + expect(await load()).toBe("loaded") + expect(attempts).toBe(2) + }) + + test("preserves readable escaped source when markdown parsing fails", () => { + expect(markdownFallback("Result \nTry 'again'")).toBe( + '

Result <unsafe>
Try 'again'

', + ) + }) +}) diff --git a/frontend/ui/src/context/marked.tsx b/frontend/ui/src/context/marked.tsx index e2d1e223..390472f6 100644 --- a/frontend/ui/src/context/marked.tsx +++ b/frontend/ui/src/context/marked.tsx @@ -1,393 +1,417 @@ -import { marked } from "marked" import type { BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" -import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" +import type { ThemeRegistrationResolved } from "@pierre/diffs" // Heavy render deps (katex ~150KB gzip, shiki grammar registry, the marked // extensions) are loaded on FIRST USE, not at module load — so first paint (the // launchpad renders no markdown/math/code) never pays for them. Each loader is // memoized after its first await. type Katex = (typeof import("katex"))["default"] -let katexP: Promise | undefined +export function retryable(load: () => Promise) { + let pending: Promise | undefined + return () => { + if (pending) return pending + pending = load().catch((error) => { + pending = undefined + throw error + }) + return pending + } +} + // Load the KaTeX engine AND its stylesheet together on first math render, so the // ~790-rule katex CSS stays out of the entry stylesheet (it's only needed once // markdown with math actually renders — never at first paint). -const loadKatex = () => - (katexP ??= Promise.all([import("katex"), import("katex/dist/katex.min.css")]).then(([m]) => m.default)) +const loadKatex = retryable(() => + Promise.all([import("katex"), import("katex/dist/katex.min.css")]).then(([module]) => module.default), +) type BundledLanguages = (typeof import("shiki"))["bundledLanguages"] -let langsP: Promise | undefined -const loadLangs = () => (langsP ??= import("shiki").then((m) => m.bundledLanguages)) +const loadLangs = retryable(() => import("shiki").then((module) => module.bundledLanguages)) -registerCustomTheme("OpenScience", () => { - return Promise.resolve({ - name: "OpenScience", - colors: { - "editor.background": "transparent", - "editor.foreground": "var(--text-base)", - "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", - "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", - // "gitDecoration.conflictingResourceForeground": "#ffca00", - // "gitDecoration.modifiedResourceForeground": "#1a76d4", - // "gitDecoration.untrackedResourceForeground": "#00cab1", - // "gitDecoration.ignoredResourceForeground": "#84848A", - // "terminal.titleForeground": "#adadb1", - // "terminal.titleInactiveForeground": "#84848A", - // "terminal.background": "#141415", - // "terminal.foreground": "#adadb1", - // "terminal.ansiBlack": "#141415", - // "terminal.ansiRed": "#ff2e3f", - // "terminal.ansiGreen": "#0dbe4e", - // "terminal.ansiYellow": "#ffca00", - // "terminal.ansiBlue": "#008cff", - // "terminal.ansiMagenta": "#c635e4", - // "terminal.ansiCyan": "#08c0ef", - // "terminal.ansiWhite": "#c6c6c8", - // "terminal.ansiBrightBlack": "#141415", - // "terminal.ansiBrightRed": "#ff2e3f", - // "terminal.ansiBrightGreen": "#0dbe4e", - // "terminal.ansiBrightYellow": "#ffca00", - // "terminal.ansiBrightBlue": "#008cff", - // "terminal.ansiBrightMagenta": "#c635e4", - // "terminal.ansiBrightCyan": "#08c0ef", - // "terminal.ansiBrightWhite": "#c6c6c8", - }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment", "string.comment"], - settings: { - foreground: "var(--syntax-comment)", - }, +const OPENSCIENCE_THEME = { + name: "OpenScience", + colors: { + "editor.background": "transparent", + "editor.foreground": "var(--text-base)", + "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", + "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + // "gitDecoration.conflictingResourceForeground": "#ffca00", + // "gitDecoration.modifiedResourceForeground": "#1a76d4", + // "gitDecoration.untrackedResourceForeground": "#00cab1", + // "gitDecoration.ignoredResourceForeground": "#84848A", + // "terminal.titleForeground": "#adadb1", + // "terminal.titleInactiveForeground": "#84848A", + // "terminal.background": "#141415", + // "terminal.foreground": "#adadb1", + // "terminal.ansiBlack": "#141415", + // "terminal.ansiRed": "#ff2e3f", + // "terminal.ansiGreen": "#0dbe4e", + // "terminal.ansiYellow": "#ffca00", + // "terminal.ansiBlue": "#008cff", + // "terminal.ansiMagenta": "#c635e4", + // "terminal.ansiCyan": "#08c0ef", + // "terminal.ansiWhite": "#c6c6c8", + // "terminal.ansiBrightBlack": "#141415", + // "terminal.ansiBrightRed": "#ff2e3f", + // "terminal.ansiBrightGreen": "#0dbe4e", + // "terminal.ansiBrightYellow": "#ffca00", + // "terminal.ansiBrightBlue": "#008cff", + // "terminal.ansiBrightMagenta": "#c635e4", + // "terminal.ansiBrightCyan": "#08c0ef", + // "terminal.ansiBrightWhite": "#c6c6c8", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment", "string.comment"], + settings: { + foreground: "var(--syntax-comment)", }, - { - scope: ["entity.other.attribute-name"], - settings: { - foreground: "var(--syntax-property)", // maybe attribute - }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { + foreground: "var(--syntax-property)", // maybe attribute }, - { - scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], - settings: { - foreground: "var(--syntax-constant)", - }, + }, + { + scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], + settings: { + foreground: "var(--syntax-constant)", }, - { - scope: ["entity.name", "meta.export.default", "meta.definition.variable"], - settings: { - foreground: "var(--syntax-type)", - }, + }, + { + scope: ["entity.name", "meta.export.default", "meta.definition.variable"], + settings: { + foreground: "var(--syntax-type)", }, - { - scope: ["meta.object.member"], - settings: { - foreground: "var(--syntax-primitive)", - }, + }, + { + scope: ["meta.object.member"], + settings: { + foreground: "var(--syntax-primitive)", }, - { - scope: [ - "variable.parameter.function", - "meta.jsx.children", - "meta.block", - "meta.tag.attributes", - "entity.name.constant", - "meta.embedded.expression", - "meta.template.expression", - "string.other.begin.yaml", - "string.other.end.yaml", - ], - settings: { - foreground: "var(--syntax-punctuation)", - }, + }, + { + scope: [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.embedded.expression", + "meta.template.expression", + "string.other.begin.yaml", + "string.other.end.yaml", + ], + settings: { + foreground: "var(--syntax-punctuation)", }, - { - scope: ["entity.name.function", "support.type.primitive"], - settings: { - foreground: "var(--syntax-primitive)", - }, + }, + { + scope: ["entity.name.function", "support.type.primitive"], + settings: { + foreground: "var(--syntax-primitive)", }, - { - scope: ["support.class.component"], - settings: { - foreground: "var(--syntax-type)", - }, + }, + { + scope: ["support.class.component"], + settings: { + foreground: "var(--syntax-type)", }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, + }, + { + scope: "keyword", + settings: { + foreground: "var(--syntax-keyword)", }, - { - scope: [ - "keyword.operator", - "storage.type.function.arrow", - "punctuation.separator.key-value.css", - "entity.name.tag.yaml", - "punctuation.separator.key-value.mapping.yaml", - ], - settings: { - foreground: "var(--syntax-operator)", - }, + }, + { + scope: [ + "keyword.operator", + "storage.type.function.arrow", + "punctuation.separator.key-value.css", + "entity.name.tag.yaml", + "punctuation.separator.key-value.mapping.yaml", + ], + settings: { + foreground: "var(--syntax-operator)", }, - { - scope: ["storage", "storage.type"], - settings: { - foreground: "var(--syntax-keyword)", - }, + }, + { + scope: ["storage", "storage.type"], + settings: { + foreground: "var(--syntax-keyword)", }, - { - scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], - settings: { - foreground: "var(--syntax-primitive)", - }, + }, + { + scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], + settings: { + foreground: "var(--syntax-primitive)", }, - { - scope: [ - "string", - "punctuation.definition.string", - "string punctuation.section.embedded source", - "entity.name.tag", - ], - settings: { - foreground: "var(--syntax-string)", - }, + }, + { + scope: [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source", + "entity.name.tag", + ], + settings: { + foreground: "var(--syntax-string)", }, - { - scope: "support", - settings: { - foreground: "var(--syntax-primitive)", - }, + }, + { + scope: "support", + settings: { + foreground: "var(--syntax-primitive)", }, - { - scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], - settings: { - foreground: "var(--syntax-object)", - }, + }, + { + scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], + settings: { + foreground: "var(--syntax-object)", }, - { - scope: "meta.property-name", - settings: { - foreground: "var(--syntax-property)", - }, + }, + { + scope: "meta.property-name", + settings: { + foreground: "var(--syntax-property)", }, - { - scope: "variable", - settings: { - foreground: "var(--syntax-variable)", - }, + }, + { + scope: "variable", + settings: { + foreground: "var(--syntax-variable)", }, - { - scope: "variable.other", - settings: { - foreground: "var(--syntax-variable)", - }, + }, + { + scope: "variable.other", + settings: { + foreground: "var(--syntax-variable)", }, - { - scope: [ - "invalid.broken", - "invalid.illegal", - "invalid.unimplemented", - "invalid.deprecated", - "message.error", - "markup.deleted", - "meta.diff.header.from-file", - "punctuation.definition.deleted", - "brackethighlighter.unmatched", - "token.error-token", - ], - settings: { - foreground: "var(--syntax-critical)", - }, + }, + { + scope: [ + "invalid.broken", + "invalid.illegal", + "invalid.unimplemented", + "invalid.deprecated", + "message.error", + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted", + "brackethighlighter.unmatched", + "token.error-token", + ], + settings: { + foreground: "var(--syntax-critical)", }, - { - scope: "carriage-return", - settings: { - foreground: "var(--syntax-keyword)", - }, + }, + { + scope: "carriage-return", + settings: { + foreground: "var(--syntax-keyword)", }, - { - scope: "string source", - settings: { - foreground: "var(--syntax-variable)", - }, + }, + { + scope: "string source", + settings: { + foreground: "var(--syntax-variable)", }, - { - scope: "string variable", - settings: { - foreground: "var(--syntax-constant)", - }, + }, + { + scope: "string variable", + settings: { + foreground: "var(--syntax-constant)", }, - { - scope: [ - "source.regexp", - "string.regexp", - "string.regexp.character-class", - "string.regexp constant.character.escape", - "string.regexp source.ruby.embedded", - "string.regexp string.regexp.arbitrary-repitition", - "string.regexp constant.character.escape", - ], - settings: { - foreground: "var(--syntax-regexp)", - }, + }, + { + scope: [ + "source.regexp", + "string.regexp", + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition", + "string.regexp constant.character.escape", + ], + settings: { + foreground: "var(--syntax-regexp)", }, - { - scope: "support.constant", - settings: { - foreground: "var(--syntax-primitive)", - }, + }, + { + scope: "support.constant", + settings: { + foreground: "var(--syntax-primitive)", }, - { - scope: "support.variable", - settings: { - foreground: "var(--syntax-variable)", - }, + }, + { + scope: "support.variable", + settings: { + foreground: "var(--syntax-variable)", }, - { - scope: "meta.module-reference", - settings: { - foreground: "var(--syntax-info)", - }, + }, + { + scope: "meta.module-reference", + settings: { + foreground: "var(--syntax-info)", }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--syntax-punctuation)", - }, + }, + { + scope: "punctuation.definition.list.begin.markdown", + settings: { + foreground: "var(--syntax-punctuation)", }, - { - scope: ["markup.heading", "markup.heading entity.name"], - settings: { - fontStyle: "bold", - foreground: "var(--syntax-info)", - }, + }, + { + scope: ["markup.heading", "markup.heading entity.name"], + settings: { + fontStyle: "bold", + foreground: "var(--syntax-info)", }, - { - scope: "markup.quote", - settings: { - foreground: "var(--syntax-info)", - }, + }, + { + scope: "markup.quote", + settings: { + foreground: "var(--syntax-info)", }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - // foreground: "", - }, + }, + { + scope: "markup.italic", + settings: { + fontStyle: "italic", + // foreground: "", }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--text-strong)", - }, + }, + { + scope: "markup.bold", + settings: { + fontStyle: "bold", + foreground: "var(--text-strong)", }, - { - scope: [ - "markup.raw", - "markup.inserted", - "meta.diff.header.to-file", - "punctuation.definition.inserted", - "markup.changed", - "punctuation.definition.changed", - "markup.ignored", - "markup.untracked", - ], - settings: { - foreground: "var(--text-base)", - }, + }, + { + scope: [ + "markup.raw", + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted", + "markup.changed", + "punctuation.definition.changed", + "markup.ignored", + "markup.untracked", + ], + settings: { + foreground: "var(--text-base)", }, - { - scope: "meta.diff.range", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: "meta.diff.range", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: "meta.diff.header", + settings: { + foreground: "var(--syntax-unknown)", }, - { - scope: "meta.separator", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: "meta.separator", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", }, - { - scope: "meta.output", - settings: { - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: "meta.output", + settings: { + foreground: "var(--syntax-unknown)", }, - { - scope: "meta.export.default", - settings: { - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: "meta.export.default", + settings: { + foreground: "var(--syntax-unknown)", }, - { - scope: [ - "brackethighlighter.tag", - "brackethighlighter.curly", - "brackethighlighter.round", - "brackethighlighter.square", - "brackethighlighter.angle", - "brackethighlighter.quote", - ], - settings: { - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote", + ], + settings: { + foreground: "var(--syntax-unknown)", }, - { - scope: ["constant.other.reference.link", "string.other.link"], - settings: { - fontStyle: "underline", - foreground: "var(--syntax-unknown)", - }, + }, + { + scope: ["constant.other.reference.link", "string.other.link"], + settings: { + fontStyle: "underline", + foreground: "var(--syntax-unknown)", }, - { - scope: "token.info-token", - settings: { - foreground: "var(--syntax-info)", - }, + }, + { + scope: "token.info-token", + settings: { + foreground: "var(--syntax-info)", }, - { - scope: "token.warn-token", - settings: { - foreground: "var(--syntax-warning)", - }, + }, + { + scope: "token.warn-token", + settings: { + foreground: "var(--syntax-warning)", }, - { - scope: "token.debug-token", - settings: { - foreground: "var(--syntax-info)", - }, + }, + { + scope: "token.debug-token", + settings: { + foreground: "var(--syntax-info)", }, - ], - semanticTokenColors: { - comment: "var(--syntax-comment)", - string: "var(--syntax-string)", - number: "var(--syntax-constant)", - regexp: "var(--syntax-regexp)", - keyword: "var(--syntax-keyword)", - variable: "var(--syntax-variable)", - parameter: "var(--syntax-variable)", - property: "var(--syntax-property)", - function: "var(--syntax-primitive)", - method: "var(--syntax-primitive)", - type: "var(--syntax-type)", - class: "var(--syntax-type)", - namespace: "var(--syntax-type)", - enumMember: "var(--syntax-primitive)", - "variable.constant": "var(--syntax-constant)", - "variable.defaultLibrary": "var(--syntax-unknown)", - }, - } as unknown as ThemeRegistrationResolved) -}) + }, + ], + semanticTokenColors: { + comment: "var(--syntax-comment)", + string: "var(--syntax-string)", + number: "var(--syntax-constant)", + regexp: "var(--syntax-regexp)", + keyword: "var(--syntax-keyword)", + variable: "var(--syntax-variable)", + parameter: "var(--syntax-variable)", + property: "var(--syntax-property)", + function: "var(--syntax-primitive)", + method: "var(--syntax-primitive)", + type: "var(--syntax-type)", + class: "var(--syntax-type)", + namespace: "var(--syntax-type)", + enumMember: "var(--syntax-primitive)", + "variable.constant": "var(--syntax-constant)", + "variable.defaultLibrary": "var(--syntax-unknown)", + }, +} as unknown as ThemeRegistrationResolved + +type DiffsModule = typeof import("@pierre/diffs") +const diffThemeRegistrars = new WeakSet() + +export function registerOpenScienceDiffTheme(diffs: Pick) { + if (diffThemeRegistrars.has(diffs.registerCustomTheme)) return + diffThemeRegistrars.add(diffs.registerCustomTheme) + diffs.registerCustomTheme("OpenScience", () => Promise.resolve(OPENSCIENCE_THEME)) +} + +const loadDiffs = retryable(() => + import("@pierre/diffs").then((diffs) => { + registerOpenScienceDiffTheme(diffs) + return diffs + }), +) function renderMathInText(text: string, katex: Katex): string { let result = text @@ -441,10 +465,8 @@ async function highlightCodeBlocks(html: string): Promise { const matches = [...html.matchAll(codeBlockRegex)] if (matches.length === 0) return html - const [highlighter, bundledLanguages] = await Promise.all([ - getSharedHighlighter({ themes: ["OpenScience"], langs: [] }), - loadLangs(), - ]) + const [diffs, bundledLanguages] = await Promise.all([loadDiffs(), loadLangs()]) + const highlighter = await diffs.getSharedHighlighter({ themes: ["OpenScience"], langs: [] }) let result = html for (const match of matches) { @@ -481,10 +503,8 @@ async function highlightCodeBlocks(html: string): Promise { * caller controls the container -- a thumbnail sizes and masks its own. */ export async function highlightSnippet(code: string, lang: string): Promise { - const [highlighter, bundled] = await Promise.all([ - getSharedHighlighter({ themes: ["OpenScience"], langs: [] }), - loadLangs(), - ]) + const [diffs, bundled] = await Promise.all([loadDiffs(), loadLangs()]) + const highlighter = await diffs.getSharedHighlighter({ themes: ["OpenScience"], langs: [] }) const language = lang in bundled ? lang : "text" if (!highlighter.getLoadedLanguages().includes(language)) { await highlighter.loadLanguage(language as BundledLanguage) @@ -497,39 +517,39 @@ export type NativeMarkdownParser = (markdown: string) => Promise // The pure-JS marked pipeline (katex + shiki extensions) — built lazily on first // use so its deps stay out of the entry chunk. Only reached when no nativeParser // is supplied (the desktop app always supplies one). -let jsParserP: Promise> | undefined -function loadJsParser() { - return (jsParserP ??= (async () => { - const [{ default: markedKatex }, { default: markedShiki }, bundledLanguages] = await Promise.all([ - import("marked-katex-extension"), - import("marked-shiki"), - loadLangs(), - ]) - return marked.use( - { - renderer: { - link({ href, title, text }) { - const titleAttr = title ? ` title="${title}"` : "" - return `${text}` - }, +type MarkedParser = ReturnType<(typeof import("marked"))["marked"]["use"]> +const loadJsParser = retryable(async () => { + const [{ marked }, { default: markedKatex }, { default: markedShiki }, bundledLanguages] = await Promise.all([ + import("marked"), + import("marked-katex-extension"), + import("marked-shiki"), + loadLangs(), + ]) + return marked.use( + { + renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` }, }, - markedKatex({ throwOnError: false, nonStandard: true }), - markedShiki({ - async highlight(code, lang) { - const highlighter = await getSharedHighlighter({ themes: ["OpenScience"], langs: [] }) - if (!(lang in bundledLanguages)) { - lang = "text" - } - if (!highlighter.getLoadedLanguages().includes(lang)) { - await highlighter.loadLanguage(lang as BundledLanguage) - } - return highlighter.codeToHtml(code, { lang: lang || "text", theme: "OpenScience", tabindex: false }) - }, - }), - ) - })()) -} + }, + markedKatex({ throwOnError: false, nonStandard: true }), + markedShiki({ + async highlight(code, lang) { + const diffs = await loadDiffs() + const highlighter = await diffs.getSharedHighlighter({ themes: ["OpenScience"], langs: [] }) + if (!(lang in bundledLanguages)) { + lang = "text" + } + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang as BundledLanguage) + } + return highlighter.codeToHtml(code, { lang: lang || "text", theme: "OpenScience", tabindex: false }) + }, + }), + ) +}) export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", @@ -539,8 +559,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( return { async parse(markdown: string): Promise { const html = await nativeParser(markdown) - const katex = await loadKatex() - const withMath = renderMathExpressions(html, katex) + const withMath = html.includes("$") ? renderMathExpressions(html, await loadKatex()) : html return highlightCodeBlocks(withMath) }, } diff --git a/frontend/ui/src/i18n/en.ts b/frontend/ui/src/i18n/en.ts index c45cd67c..9cc7a0e3 100644 --- a/frontend/ui/src/i18n/en.ts +++ b/frontend/ui/src/i18n/en.ts @@ -101,7 +101,7 @@ export const dict = { "ui.message.copy": "Copy", "ui.message.copied": "Copied!", "ui.message.revert": "Undo from here", - "ui.message.attachment.alt": "attachment", + "ui.message.attachment.alt": "Attachment", "ui.patch.action.deleted": "Deleted", "ui.patch.action.created": "Created", diff --git a/frontend/ui/src/pierre/index.ts b/frontend/ui/src/pierre/index.ts index 487cb1ed..d3435832 100644 --- a/frontend/ui/src/pierre/index.ts +++ b/frontend/ui/src/pierre/index.ts @@ -1,5 +1,14 @@ -import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs" +import { + DiffLineAnnotation, + FileContents, + FileDiffOptions, + registerCustomTheme, + type SelectedLineRange, +} from "@pierre/diffs" import { ComponentProps } from "solid-js" +import { registerOpenScienceDiffTheme } from "../context/marked" + +registerOpenScienceDiffTheme({ registerCustomTheme }) export type DiffProps = FileDiffOptions & { before: FileContents diff --git a/frontend/ui/src/styles/animations.css b/frontend/ui/src/styles/animations.css index ea4c8431..7096a0c1 100644 --- a/frontend/ui/src/styles/animations.css +++ b/frontend/ui/src/styles/animations.css @@ -34,99 +34,8 @@ } .fade-up-text { - animation: fadeUp 0.4s ease-out forwards; + animation: fadeUp var(--duration-slow) var(--ease-out-expo) forwards; opacity: 0; - - &:nth-child(1) { - animation-delay: 0.1s; - } - &:nth-child(2) { - animation-delay: 0.2s; - } - &:nth-child(3) { - animation-delay: 0.3s; - } - &:nth-child(4) { - animation-delay: 0.4s; - } - &:nth-child(5) { - animation-delay: 0.5s; - } - &:nth-child(6) { - animation-delay: 0.6s; - } - &:nth-child(7) { - animation-delay: 0.7s; - } - &:nth-child(8) { - animation-delay: 0.8s; - } - &:nth-child(9) { - animation-delay: 0.9s; - } - &:nth-child(10) { - animation-delay: 1s; - } - &:nth-child(11) { - animation-delay: 1.1s; - } - &:nth-child(12) { - animation-delay: 1.2s; - } - &:nth-child(13) { - animation-delay: 1.3s; - } - &:nth-child(14) { - animation-delay: 1.4s; - } - &:nth-child(15) { - animation-delay: 1.5s; - } - &:nth-child(16) { - animation-delay: 1.6s; - } - &:nth-child(17) { - animation-delay: 1.7s; - } - &:nth-child(18) { - animation-delay: 1.8s; - } - &:nth-child(19) { - animation-delay: 1.9s; - } - &:nth-child(20) { - animation-delay: 2s; - } - &:nth-child(21) { - animation-delay: 2.1s; - } - &:nth-child(22) { - animation-delay: 2.2s; - } - &:nth-child(23) { - animation-delay: 2.3s; - } - &:nth-child(24) { - animation-delay: 2.4s; - } - &:nth-child(25) { - animation-delay: 2.5s; - } - &:nth-child(26) { - animation-delay: 2.6s; - } - &:nth-child(27) { - animation-delay: 2.7s; - } - &:nth-child(28) { - animation-delay: 2.8s; - } - &:nth-child(29) { - animation-delay: 2.9s; - } - &:nth-child(30) { - animation-delay: 3s; - } } /* Reduced motion: decorative entrances and infinite attention loops become @@ -134,12 +43,13 @@ pattern the app uses for .atlas-* animations. */ @media (prefers-reduced-motion: reduce) { .fade-up-text, - [data-component="dialog"] [data-slot="dialog-overlay"], - [data-component="dialog"] [data-slot="dialog-container"], + [data-component="dialog-overlay"], + [data-slot="dialog-container"], [data-component="toast"], - [data-component="select"] [data-slot="select-content"], - [data-component="popover"], + [data-component="select-content"], + [data-component="popover-content"], [data-component="dropdown-menu-content"], + [data-component="dropdown-menu-sub-content"], [data-component="hover-card-content"] { animation-duration: 0.01ms !important; animation-delay: 0s !important; diff --git a/frontend/ui/src/styles/base.css b/frontend/ui/src/styles/base.css index 9fbe65e4..d8a68839 100644 --- a/frontend/ui/src/styles/base.css +++ b/frontend/ui/src/styles/base.css @@ -22,7 +22,8 @@ 4. Use the user's configured `sans` font-family by default. 5. Use the user's configured `sans` font-feature-settings by default. 6. Use the user's configured `sans` font-variation-settings by default. - 7. Disable tap highlights on iOS. + 7. Apply the shared quiet body weight by default. + 8. Disable tap highlights on iOS. */ html, @@ -33,7 +34,8 @@ html, font-family: var(--font-family-sans); /* 4 */ font-feature-settings: var(--font-family-sans--font-feature-settings, normal); /* 5 */ font-variation-settings: var(--font-family-sans--font-variation-settings, normal); /* 6 */ - -webkit-tap-highlight-color: transparent; /* 7 */ + font-weight: var(--font-weight-regular); /* 7 */ + -webkit-tap-highlight-color: transparent; /* 8 */ /* Reserve scrollbar space even when content fits — without this, modal scroll-lock removes the scrollbar and the page shifts ~15px right, which reads as a flicker the moment a dialog spawns. */ diff --git a/frontend/ui/src/styles/motion-contract.test.ts b/frontend/ui/src/styles/motion-contract.test.ts new file mode 100644 index 00000000..fd75547b --- /dev/null +++ b/frontend/ui/src/styles/motion-contract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" + +const read = (path: string) => Bun.file(new URL(path, import.meta.url)).text() + +describe("shared motion contract", () => { + test("uses tokenized, property-specific pressed feedback", async () => { + const [button, iconButton, toast] = await Promise.all([ + read("../components/button.css"), + read("../components/icon-button.css"), + read("../components/toast.css"), + ]) + + for (const css of [button, iconButton, toast]) expect(css).not.toContain("transition: all") + expect(button).toContain("transform: scale(0.98)") + expect(iconButton).toContain("transform: scale(0.98)") + expect(button).toContain("transform var(--duration-fast) var(--ease-standard)") + expect(toast).toContain("transition: transform var(--duration-slow) var(--ease-out-expo)") + }) + + test("mounts menus immediately and gives dismissals a short ease-in exit", async () => { + const [dropdown, select, popover, hoverCard, toast] = await Promise.all([ + read("../components/dropdown-menu.css"), + read("../components/select.css"), + read("../components/popover.css"), + read("../components/hover-card.css"), + read("../components/toast.css"), + ]) + + expect(dropdown).not.toContain("dropdown-menu-open") + expect(select).not.toContain("select-open") + expect(select).not.toContain("background-color 0.2s ease-in-out") + expect(dropdown).toContain("dropdown-menu-close var(--duration-fast) ease-in forwards") + expect(select).toContain("select-close var(--duration-fast) ease-in forwards") + expect(popover).toContain("popover-close var(--duration-fast) ease-in forwards") + expect(hoverCard).toContain("hover-card-close var(--duration-fast) ease-in forwards") + expect(toast).toContain("toastPopOut var(--duration-fast) ease-in forwards") + }) + + test("targets the portal roots when motion is reduced", async () => { + const animations = await read("./animations.css") + + expect(animations).toContain('[data-component="dialog-overlay"]') + expect(animations).toContain('[data-component="select-content"]') + expect(animations).toContain('[data-component="popover-content"]') + expect(animations).toContain('[data-component="dropdown-menu-sub-content"]') + expect(animations).not.toContain('[data-component="select"] [data-slot="select-content"]') + expect(animations).not.toContain('[data-component="popover"],') + expect(animations).not.toContain("&:nth-child") + }) + + test("removes icon-button press motion when reduced motion is requested", async () => { + const iconButton = await read("../components/icon-button.css") + + expect(iconButton).toMatch( + /@media \(prefers-reduced-motion: reduce\)[\s\S]*\[data-component="icon-button"\][\s\S]*transition:\s*none[\s\S]*transform:\s*none/, + ) + }) + + test("keeps native text selection restrained and legible", async () => { + const utilities = await read("./utilities.css") + + expect(utilities).toContain("::selection") + expect(utilities).toContain("color-mix(in srgb, var(--color-primary) 24%, transparent)") + expect(utilities).toContain("color: inherit") + }) +}) diff --git a/frontend/ui/src/styles/radius-contract.test.ts b/frontend/ui/src/styles/radius-contract.test.ts new file mode 100644 index 00000000..dabd89d4 --- /dev/null +++ b/frontend/ui/src/styles/radius-contract.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join, relative } from "node:path" +import { fileURLToPath } from "node:url" + +const sourceRoot = fileURLToPath(new URL("../", import.meta.url)) + +const cssFiles = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return cssFiles(path) + return entry.isFile() && entry.name.endsWith(".css") ? [path] : [] + }) + +const stripComments = (source: string) => source.replace(/\/\*[\s\S]*?\*\//g, "") + +const tokenRadius = String.raw`var\(--radius-(?:xs|sm|md|lg|xl)\)` +const semanticRadius = new RegExp(String.raw`^(?:0|50%|${tokenRadius})(?:\s+(?:0|50%|${tokenRadius}))*$`) + +describe("shared UI radius contract", () => { + test("uses the shared ladder outside documented scrollbar micro-geometry", () => { + const violations: string[] = [] + const scrollbarExceptions: string[] = [] + + for (const file of cssFiles(sourceRoot)) { + const name = relative(sourceRoot, file) + let source = stripComments(readFileSync(file, "utf8")) + + if (name === "styles/tailwind/utilities.css") { + source = source.replace( + /&::-webkit-scrollbar-(track|thumb)\s*\{[^{}]*?border-radius:\s*2px;[^{}]*?\}/g, + (block, part: string) => { + scrollbarExceptions.push(part) + return block.replace("border-radius: 2px", "border-radius: var(--radius-xs)") + }, + ) + } + + for (const match of source.matchAll(/border(?:-[a-z]+)*-radius\s*:\s*([^;{}]+)/g)) { + const value = match[1]!.trim() + if (semanticRadius.test(value)) continue + + const line = source.slice(0, match.index).split("\n").length + violations.push(`${name}:${line}: ${value}`) + } + } + + // The 10px Tailwind scrollbar uses a 3px transparent inset. Its 2px track + // and thumb corners are rendering mechanics, not component geometry. + expect(scrollbarExceptions.sort()).toEqual(["thumb", "track"]) + expect(violations).toEqual([]) + }) +}) diff --git a/frontend/ui/src/styles/tailwind/index.css b/frontend/ui/src/styles/tailwind/index.css index 7b867e09..5eef25fe 100644 --- a/frontend/ui/src/styles/tailwind/index.css +++ b/frontend/ui/src/styles/tailwind/index.css @@ -44,8 +44,10 @@ --text-lg: var(--font-size-large); --text-xl: var(--font-size-x-large); + --font-weight-normal: var(--font-weight-regular); --font-weight-regular: var(--font-weight-regular); --font-weight-medium: var(--font-weight-medium); + --font-weight-semibold: var(--font-weight-emphasis); --leading-lg: var(--line-height-large); --leading-xl: var(--line-height-x-large); diff --git a/frontend/ui/src/styles/theme.css b/frontend/ui/src/styles/theme.css index eb988d68..b40b968f 100644 --- a/frontend/ui/src/styles/theme.css +++ b/frontend/ui/src/styles/theme.css @@ -18,8 +18,13 @@ --font-size-base: 14px; --font-size-large: 16px; --font-size-x-large: 20px; - --font-weight-regular: 400; - --font-weight-medium: 500; + /* A deliberately quiet three-step scale. Inter Variable can render the + in-between values precisely; static fallbacks resolve to their nearest + available face. Keep body and controls light, and reserve emphasis for + real hierarchy instead of making every interactive label bold. */ + --font-weight-regular: 380; + --font-weight-medium: 480; + --font-weight-emphasis: 500; --line-height-normal: 140%; --line-height-large: 150%; --line-height-x-large: 180%; @@ -397,111 +402,111 @@ --text-mix-blend-mode: plus-lighter; /* OpenScience fallback variables (dark) */ - --background-base: #1c1d1c; - --background-weak: #202120; - --background-strong: #181918; - --background-stronger: #141514; - --surface-base: #f2f1ec0a; - --base: #f2f1ec0a; - --surface-base-hover: #f2f1ec12; - --surface-base-active: #f2f1ec14; - --surface-base-interactive-active: #d4876529; - --base2: #f2f1ec0a; - --base3: #f2f1ec0a; - --surface-inset-base: #0f0f0e7f; - --surface-inset-base-hover: #0f0f0e7f; - --surface-inset-strong: #080807cc; - --surface-inset-strong-hover: #080807cc; - --surface-raised-base: #f2f1ec05; - --surface-float-base: #191917; - --surface-float-base-hover: #1e1e1b; - --surface-raised-base-hover: #f2f1ec0a; - --surface-raised-base-active: #f2f1ec14; - --surface-raised-strong: #252625; - --surface-raised-strong-hover: #2a2b2a; - --surface-raised-stronger: #2a2b2a; - --surface-raised-stronger-hover: #30312f; - --surface-weak: #f2f1ec14; - --surface-weaker: #f2f1ec1f; - --surface-strong: #2d2e2c; - --surface-raised-stronger-non-alpha: #252625; + --background-base: #26241f; + --background-weak: #2d2a24; + --background-strong: #2a2822; + --background-stronger: #302d27; + --surface-base: #fffaf208; + --base: #fffaf208; + --surface-base-hover: #fffaf20f; + --surface-base-active: #fffaf216; + --surface-base-interactive-active: #d4876540; + --base2: #fffaf208; + --base3: #fffaf208; + --surface-inset-base: #23211d; + --surface-inset-base-hover: #27241f; + --surface-inset-strong: #201e1a; + --surface-inset-strong-hover: #24211d; + --surface-raised-base: #fffaf208; + --surface-float-base: #34312b; + --surface-float-base-hover: #3e3931; + --surface-raised-base-hover: #fffaf20f; + --surface-raised-base-active: #fffaf216; + --surface-raised-strong: #34312b; + --surface-raised-strong-hover: #3a362f; + --surface-raised-stronger: #38342d; + --surface-raised-stronger-hover: #423d35; + --surface-weak: #fffaf20a; + --surface-weaker: #fffaf210; + --surface-strong: #3e3931; + --surface-raised-stronger-non-alpha: #34312b; --surface-brand-base: #d48765; - --surface-brand-hover: #e09a78; - --surface-interactive-base: #492d24; - --surface-interactive-hover: #5b3529; - --surface-interactive-weak: #35251f; - --surface-interactive-weak-hover: #492d24; - --surface-success-base: #111e15; - --surface-success-weak: #0c140e; + --surface-brand-hover: #e5a181; + --surface-interactive-base: #4a3027; + --surface-interactive-hover: #5a382c; + --surface-interactive-weak: #33261f; + --surface-interactive-weak-hover: #3e2b23; + --surface-success-base: #24362b; + --surface-success-weak: #202d25; --surface-success-strong: #79b58d; - --surface-warning-base: #231907; - --surface-warning-weak: #171006; + --surface-warning-base: #3b3020; + --surface-warning-weak: #30291f; --surface-warning-strong: #d4a557; - --surface-critical-base: var(--ember-dark-3); - --surface-critical-weak: var(--ember-dark-2); - --surface-critical-strong: var(--ember-dark-9); - --surface-info-base: #141c22; - --surface-info-weak: #0d1216; - --surface-info-strong: #8fb4d0; - --surface-diff-unchanged-base: #191917; - --surface-diff-skip-base: #f2f1ec05; - --surface-diff-hidden-base: var(--blue-dark-2); - --surface-diff-hidden-weak: var(--blue-dark-1); - --surface-diff-hidden-weaker: var(--blue-dark-3); - --surface-diff-hidden-strong: var(--blue-dark-5); - --surface-diff-hidden-stronger: var(--blue-dark-11); - --surface-diff-add-base: var(--mint-dark-3); - --surface-diff-add-weak: var(--mint-dark-4); - --surface-diff-add-weaker: var(--mint-dark-3); - --surface-diff-add-strong: var(--mint-dark-5); - --surface-diff-add-stronger: var(--mint-dark-11); - --surface-diff-delete-base: var(--ember-dark-3); - --surface-diff-delete-weak: var(--ember-dark-4); - --surface-diff-delete-weaker: var(--ember-dark-3); - --surface-diff-delete-strong: var(--ember-dark-5); - --surface-diff-delete-stronger: var(--ember-dark-11); - --input-base: #242421; - --input-hover: #292926; - --input-active: #2e211c; - --input-selected: #4a3027; - --input-focus: #2e211c; - --input-disabled: #383834; - --text-base: #deddd8; - --text-weak: #b0afa9; - --text-weaker: #85847f; - --text-strong: #f0efeb; + --surface-critical-base: #412b25; + --surface-critical-weak: #352520; + --surface-critical-strong: #df7b68; + --surface-info-base: #322c39; + --surface-info-weak: #2b2731; + --surface-info-strong: #a99bbc; + --surface-diff-unchanged-base: #26241f; + --surface-diff-skip-base: #2a2822; + --surface-diff-hidden-base: #33261f; + --surface-diff-hidden-weak: #2e251f; + --surface-diff-hidden-weaker: #3a2922; + --surface-diff-hidden-strong: #5a382c; + --surface-diff-hidden-stronger: #e19a7c; + --surface-diff-add-base: #24362b; + --surface-diff-add-weak: #2a3e31; + --surface-diff-add-weaker: #24362b; + --surface-diff-add-strong: #34513e; + --surface-diff-add-stronger: #87bd98; + --surface-diff-delete-base: #412b25; + --surface-diff-delete-weak: #4a3029; + --surface-diff-delete-weaker: #412b25; + --surface-diff-delete-strong: #5f392f; + --surface-diff-delete-stronger: #ee9280; + --input-base: #302d27; + --input-hover: #37332c; + --input-active: #3a2c25; + --input-selected: #493328; + --input-focus: #3a2c25; + --input-disabled: #2c2924; + --text-base: #e3ded5; + --text-weak: #c0b8ad; + --text-weaker: #b0a79c; + --text-strong: #faf6ef; --text-invert-base: var(--smoke-dark-alpha-11); --text-invert-weak: var(--smoke-dark-alpha-9); --text-invert-weaker: var(--smoke-dark-alpha-8); --text-invert-strong: var(--smoke-dark-alpha-12); - --text-interactive-base: #e09a78; - --text-on-brand-base: #1e1e1b; + --text-interactive-base: #e5a181; + --text-on-brand-base: #231f1a; --text-on-interactive-base: var(--smoke-dark-12); --text-on-interactive-weak: var(--smoke-dark-alpha-11); --text-on-success-base: #79b58d; --text-on-critical-base: var(--ember-dark-9); --text-on-critical-weak: var(--ember-dark-8); --text-on-critical-strong: var(--ember-dark-12); - --text-on-warning-base: #b6b7b5; - --text-on-info-base: #b6b7b5; + --text-on-warning-base: #bfb6aa; + --text-on-info-base: #bfb6aa; --text-diff-add-base: var(--mint-dark-11); --text-diff-delete-base: var(--ember-dark-9); --text-diff-delete-strong: var(--ember-dark-12); --text-diff-add-strong: var(--mint-dark-8); - --text-on-info-weak: #646663; - --text-on-info-strong: #eff0ee; - --text-on-warning-weak: #646663; - --text-on-warning-strong: #eff0ee; + --text-on-info-weak: #6c645a; + --text-on-info-strong: #f8eee2; + --text-on-warning-weak: #6c645a; + --text-on-warning-strong: #f8eee2; --text-on-success-weak: #518161; --text-on-success-strong: #cef3d9; - --text-on-brand-weak: #1e1e1b; - --text-on-brand-weaker: #1e1e1b; - --text-on-brand-strong: #1e1e1b; + --text-on-brand-weak: #231f1a; + --text-on-brand-weaker: #231f1a; + --text-on-brand-strong: #231f1a; --button-primary-base: var(--smoke-dark-12); - --button-secondary-base: #292926; - --button-secondary-hover: #30302d; - --border-base: #f0efeb27; - --border-hover: #f0efeb34; + --button-secondary-base: #34312b; + --button-secondary-hover: #3e3931; + --border-base: #f4eee52e; + --border-hover: #f4eee53d; --border-active: #f2f1ec67; --border-selected: #d48765; --border-disabled: #f2f1ec59; @@ -511,14 +516,14 @@ --focus-lit-ring: rgba(255, 255, 255, 0.2); --focus-lit-wash: rgba(255, 255, 255, 0.07); --focus-lit-bloom: rgba(255, 255, 255, 0.06); - --border-weak-base: #f0efeb12; - --border-strong-base: #f0efeb43; + --border-weak-base: #f4eee514; + --border-strong-base: #f4eee54d; --border-strong-hover: #f2f1ec3f; --border-strong-active: #f2f1ec59; --border-strong-selected: #d4876566; --border-strong-disabled: #f2f1ec31; --border-strong-focus: #f2f1ec59; - --border-weak-hover: #f0efeb1d; + --border-weak-hover: #f4eee522; --border-weak-active: #f2f1ec59; --border-weak-selected: #d487654d; --border-weak-disabled: #f2f1ec31; @@ -538,50 +543,50 @@ --border-critical-base: var(--ember-dark-5); --border-critical-hover: var(--ember-dark-7); --border-critical-selected: var(--ember-dark-9); - --border-info-base: #324553; - --border-info-hover: #445b6d; - --border-info-selected: #8fb4d0; - --icon-base: #8d8a84; - --icon-hover: #b5b2ab; - --icon-active: #e2e0da; - --icon-selected: #f2f1ec; - --icon-disabled: #696966; - --icon-focus: #f2f1ec; - --icon-invert-base: #191917; - --icon-weak-base: #696966; - --icon-weak-hover: #7a7a77; - --icon-weak-active: #8d8a84; - --icon-weak-selected: #b5b2ab; - --icon-weak-disabled: #484845; - --icon-weak-focus: #8d8a84; - --icon-strong-base: #f2f1ec; - --icon-strong-hover: #f7f6f2; - --icon-strong-active: #fcfbf7; - --icon-strong-selected: #fdfcf8; - --icon-strong-disabled: #8d8a84; - --icon-strong-focus: #fdfcf8; - --icon-brand-base: var(--white); - --icon-interactive-base: var(--cobalt-dark-9); - --icon-success-base: #3c6249; - --icon-success-hover: #518161; - --icon-success-active: #87bd98; - --icon-warning-base: #6f521f; - --icon-warning-hover: #926d2c; - --icon-warning-active: #d0a662; - --icon-critical-base: var(--ember-dark-9); - --icon-critical-hover: var(--ember-dark-11); - --icon-critical-active: var(--ember-dark-12); - --icon-info-base: #445b6d; - --icon-info-hover: #5b798f; - --icon-info-active: #92b3cc; - --icon-on-brand-base: #1e1e1b; - --icon-on-brand-hover: #1e1e1b; - --icon-on-brand-selected: #1e1e1b; + --border-info-base: #463f4f; + --border-info-hover: #5c5368; + --border-info-selected: #a99bbc; + --icon-base: #c0b8ad; + --icon-hover: #e3ded5; + --icon-active: #faf6ef; + --icon-selected: #faf6ef; + --icon-disabled: #756d63; + --icon-focus: #faf6ef; + --icon-invert-base: #231f1a; + --icon-weak-base: #958b7f; + --icon-weak-hover: #aaa196; + --icon-weak-active: #c0b8ad; + --icon-weak-selected: #e3ded5; + --icon-weak-disabled: #6e665d; + --icon-weak-focus: #c0b8ad; + --icon-strong-base: #faf0e4; + --icon-strong-hover: #f6f3f3; + --icon-strong-active: #fcfcfc; + --icon-strong-selected: #fdfcfc; + --icon-strong-disabled: #70675d; + --icon-strong-focus: #fdfcfc; + --icon-brand-base: #d48765; + --icon-interactive-base: #d48765; + --icon-success-base: #79b58d; + --icon-success-hover: #8fc69f; + --icon-success-active: #a7d6b5; + --icon-warning-base: #d4a557; + --icon-warning-hover: #e0b66f; + --icon-warning-active: #ecc985; + --icon-critical-base: #df7b68; + --icon-critical-hover: #ed927f; + --icon-critical-active: #f8ad9c; + --icon-info-base: #a99bbc; + --icon-info-hover: #bbaeca; + --icon-info-active: #cfc3da; + --icon-on-brand-base: #231f1a; + --icon-on-brand-hover: #231f1a; + --icon-on-brand-selected: #231f1a; --icon-on-interactive-base: var(--smoke-dark-12); - --icon-agent-plan-base: var(--purple-dark-9); - --icon-agent-docs-base: var(--amber-dark-9); - --icon-agent-ask-base: var(--cyan-dark-9); - --icon-agent-build-base: var(--cobalt-dark-11); + --icon-agent-plan-base: #a99bbc; + --icon-agent-docs-base: #d4a557; + --icon-agent-ask-base: #d48765; + --icon-agent-build-base: #e19a7c; --icon-on-success-base: rgba(121, 181, 141, 0.9); --icon-on-success-hover: rgba(106, 165, 126, 0.9); --icon-on-success-selected: rgba(135, 189, 152, 0.9); @@ -591,9 +596,9 @@ --icon-on-critical-base: var(--ember-dark-alpha-9); --icon-on-critical-hover: var(--ember-dark-alpha-10); --icon-on-critical-selected: var(--ember-dark-alpha-11); - --icon-on-info-base: #8fb4d0; - --icon-on-info-hover: rgba(128, 164, 192, 0.9); - --icon-on-info-selected: rgba(146, 179, 204, 0.9); + --icon-on-info-base: #a99bbc; + --icon-on-info-hover: rgba(154, 140, 172, 0.9); + --icon-on-info-selected: rgba(180, 167, 197, 0.9); --icon-diff-add-base: var(--mint-dark-11); --icon-diff-add-hover: var(--mint-dark-10); --icon-diff-add-active: var(--mint-dark-11); @@ -605,43 +610,43 @@ --syntax-string: #79b58d; --syntax-keyword: #e39a77; --syntax-primitive: #d4a557; - --syntax-operator: #b5b2ab; + --syntax-operator: #c0b8ad; --syntax-variable: var(--text-strong); --syntax-property: #d48765; - --syntax-type: #8fb4d0; + --syntax-type: #b7a8c4; --syntax-constant: #c89468; --syntax-punctuation: var(--text-weak); --syntax-object: var(--text-strong); --syntax-success: #6aa57e; --syntax-warning: #c49547; --syntax-critical: var(--ember-dark-10); - --syntax-info: #8fb4d0; + --syntax-info: #b7a8c4; --syntax-diff-add: var(--mint-dark-11); --syntax-diff-delete: var(--ember-dark-11); --syntax-diff-unknown: #ff0000; - --markdown-heading: #e09a78; - --markdown-text: #e2e0da; + --markdown-heading: #e5a181; + --markdown-text: #e3ded5; --markdown-link: #d48765; - --markdown-link-text: #8fb4d0; + --markdown-link-text: #b7a8c4; --markdown-code: #e39a77; --markdown-block-quote: #d4a557; --markdown-emph: #d4a557; --markdown-strong: #e39a77; - --markdown-horizontal-rule: #484845; + --markdown-horizontal-rule: #5c554b; --markdown-list-item: #d48765; - --markdown-list-enumeration: #8fb4d0; + --markdown-list-enumeration: #b7a8c4; --markdown-image: #d48765; - --markdown-image-text: #8fb4d0; - --markdown-code-block: #e2e0da; + --markdown-image-text: #b7a8c4; + --markdown-code-block: #e3ded5; --border-color: #ffffff; - --border-weaker-base: #f0efeb08; - --border-weaker-hover: #f2f1ec1e; - --border-weaker-active: #f2f1ec31; + --border-weaker-base: #f4eee50a; + --border-weaker-hover: #f4eee512; + --border-weaker-active: #f4eee51c; --border-weaker-selected: var(--cobalt-dark-alpha-3); - --border-weaker-disabled: #f2f1ec0a; + --border-weaker-disabled: #f4eee506; --border-weaker-focus: #f2f1ec31; - --button-ghost-hover: #f2f1ec0a; - --button-ghost-hover2: #f2f1ec14; + --button-ghost-hover: #fffaf20a; + --button-ghost-hover2: #fffaf210; --avatar-background-pink: #501b3f; --avatar-background-mint: #033a34; --avatar-background-orange: #5f2a06; diff --git a/frontend/ui/src/styles/typography-contract.test.ts b/frontend/ui/src/styles/typography-contract.test.ts new file mode 100644 index 00000000..66885fde --- /dev/null +++ b/frontend/ui/src/styles/typography-contract.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" + +const theme = await Bun.file(new URL("./theme.css", import.meta.url)).text() +const base = await Bun.file(new URL("./base.css", import.meta.url)).text() +const tailwind = await Bun.file(new URL("./tailwind/index.css", import.meta.url)).text() +const components = await Array.fromAsync( + new Bun.Glob("*.css").scan({ + cwd: new URL("../components/", import.meta.url).pathname, + absolute: true, + }), +) + +describe("shared typography contract", () => { + test("uses one quiet semantic weight scale throughout the app shell", () => { + expect(theme).toContain("--font-weight-regular: 380") + expect(theme).toContain("--font-weight-medium: 480") + expect(theme).toContain("--font-weight-emphasis: 500") + expect(base).toMatch(/html,\s*:host\s*\{[^}]*font-weight: var\(--font-weight-regular\)/s) + expect(tailwind).toContain("--font-weight-normal: var(--font-weight-regular)") + expect(tailwind).toContain("--font-weight-medium: var(--font-weight-medium)") + expect(tailwind).toContain("--font-weight-semibold: var(--font-weight-emphasis)") + }) + + test("keeps shared component hierarchy semantic instead of hard-coded or all caps", async () => { + for (const path of components) { + const css = await Bun.file(path).text() + expect(css.match(/(? { + expect(base).toMatch(/b,\s*strong\s*\{\s*font-weight: bolder;/s) + expect(base).toMatch(/code,\s*kbd,\s*samp,\s*pre\s*\{[^}]*font-family: var\(--font-family-mono\)/s) + }) +}) diff --git a/frontend/ui/src/styles/utilities.css b/frontend/ui/src/styles/utilities.css index 5fc2d97b..1c4d28a6 100644 --- a/frontend/ui/src/styles/utilities.css +++ b/frontend/ui/src/styles/utilities.css @@ -5,11 +5,10 @@ pointer-events: none; } - /* ::selection { */ - /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */ - /* background-color: var(--color-primary); */ - /* color: var(--color-background); */ - /* } */ + ::selection { + background-color: color-mix(in srgb, var(--color-primary) 24%, transparent); + color: inherit; + } ::-webkit-scrollbar { width: 8px; diff --git a/frontend/ui/src/theme/context.tsx b/frontend/ui/src/theme/context.tsx index 775da938..40f6f73e 100644 --- a/frontend/ui/src/theme/context.tsx +++ b/frontend/ui/src/theme/context.tsx @@ -10,12 +10,14 @@ export type ColorScheme = "light" | "dark" | "system" const STORAGE_KEYS = { THEME_ID: "openscience-theme-id", COLOR_SCHEME: "openscience-color-scheme", - THEME_CSS_LIGHT: "openscience-theme-css-light", - THEME_CSS_DARK: "openscience-theme-css-dark", + LEGACY_THEME_CSS_LIGHT: "openscience-theme-css-light", + LEGACY_THEME_CSS_DARK: "openscience-theme-css-dark", } as const const THEME_STYLE_ID = "openscience-theme" +const themeCssKey = (themeId: string, mode: "light" | "dark") => `openscience-theme-css-${themeId}-${mode}` + function ensureThemeStyleElement(): HTMLStyleElement { const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null if (existing) return existing @@ -29,17 +31,20 @@ function getSystemMode(): "light" | "dark" { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } +function getStoredColorScheme(): ColorScheme | undefined { + const scheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) + if (scheme === "system" || scheme === "light" || scheme === "dark") return scheme +} + function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark") { const isDark = mode === "dark" const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - if (themeId !== "openscience-1") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} - } + try { + localStorage.setItem(themeCssKey(themeId, mode), css) + } catch {} const fullCss = `:root { color-scheme: ${mode}; @@ -51,29 +56,37 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da ensureThemeStyleElement().textContent = fullCss document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute("content", tokens["background-base"]) } function cacheThemeVariants(theme: DesktopTheme, themeId: string) { - if (themeId === "openscience-1") return for (const mode of ["light", "dark"] as const) { const isDark = mode === "dark" const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + localStorage.setItem(themeCssKey(themeId, mode), css) } catch {} } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", - init: (props: { defaultTheme?: string }) => { + init: (props: { defaultTheme?: string; lockedTheme?: string; lockedScheme?: Exclude }) => { + const lockedTheme = props.lockedTheme && DEFAULT_THEMES[props.lockedTheme] ? props.lockedTheme : undefined + const lockedScheme = props.lockedScheme + // OpenScience is a long-running research workbench, so new installations + // start in its lower-glare dark appearance. System and Light remain real, + // persisted choices once the user selects them. + const initialScheme = lockedScheme ?? getStoredColorScheme() ?? "dark" const [store, setStore] = createStore({ themes: DEFAULT_THEMES as Record, - themeId: props.defaultTheme ?? "openscience", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themeId: lockedTheme ?? props.defaultTheme ?? "openscience", + colorScheme: initialScheme, + mode: initialScheme === "system" ? getSystemMode() : initialScheme, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) @@ -89,11 +102,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onCleanup(() => mediaQuery.removeEventListener("change", handler)) const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (savedTheme && store.themes[savedTheme]) { + const savedScheme = getStoredColorScheme() + if (lockedTheme) { + setStore("themeId", lockedTheme) + localStorage.setItem(STORAGE_KEYS.THEME_ID, lockedTheme) + localStorage.removeItem(STORAGE_KEYS.LEGACY_THEME_CSS_LIGHT) + localStorage.removeItem(STORAGE_KEYS.LEGACY_THEME_CSS_DARK) + } else if (savedTheme && store.themes[savedTheme]) { setStore("themeId", savedTheme) } - if (savedScheme) { + if (lockedScheme) { + setStore("colorScheme", lockedScheme) + setStore("mode", lockedScheme) + localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, lockedScheme) + } else if (savedScheme) { setStore("colorScheme", savedScheme) if (savedScheme !== "system") { setStore("mode", savedScheme) @@ -113,6 +135,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) const setTheme = (id: string) => { + if (lockedTheme && id !== lockedTheme) return const theme = store.themes[id] if (!theme) { console.warn(`Theme "${id}" not found`) @@ -124,6 +147,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } const setColorScheme = (scheme: ColorScheme) => { + if (lockedScheme && scheme !== lockedScheme) return setStore("colorScheme", scheme) localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) @@ -138,6 +162,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setColorScheme, registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme), previewTheme: (id: string) => { + if (lockedTheme && id !== lockedTheme) return const theme = store.themes[id] if (!theme) return setStore("previewThemeId", id) @@ -149,6 +174,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ applyThemeCss(theme, id, previewMode) }, previewColorScheme: (scheme: ColorScheme) => { + if (lockedScheme && scheme !== lockedScheme) return setStore("previewScheme", scheme) const previewMode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId diff --git a/frontend/ui/src/theme/openscience-theme.test.ts b/frontend/ui/src/theme/openscience-theme.test.ts index 6d689be0..02d0be2d 100644 --- a/frontend/ui/src/theme/openscience-theme.test.ts +++ b/frontend/ui/src/theme/openscience-theme.test.ts @@ -22,26 +22,33 @@ const fallback = { } const darkPalette = { - "background-base": "#1c1d1c", - "background-weak": "#202120", - "background-strong": "#181918", - "background-stronger": "#141514", - "surface-raised-strong": "#252625", - "surface-raised-strong-hover": "#2a2b2a", - "surface-raised-stronger": "#2a2b2a", - "surface-raised-stronger-hover": "#30312f", - "surface-strong": "#2d2e2c", - "surface-raised-stronger-non-alpha": "#252625", - "border-weaker-base": "#f0efeb08", - "border-weak-base": "#f0efeb12", - "border-weak-hover": "#f0efeb1d", - "border-base": "#f0efeb27", - "border-hover": "#f0efeb34", - "border-strong-base": "#f0efeb43", - "text-base": "#deddd8", - "text-weak": "#b0afa9", - "text-weaker": "#85847f", - "text-strong": "#f0efeb", + "background-base": "#26241f", + "background-weak": "#2d2a24", + "background-strong": "#2a2822", + "background-stronger": "#302d27", + "surface-inset-base": "#23211d", + "surface-inset-strong": "#201e1a", + "surface-float-base": "#34312b", + "surface-raised-strong": "#34312b", + "surface-raised-strong-hover": "#3a362f", + "surface-raised-stronger": "#38342d", + "surface-raised-stronger-hover": "#423d35", + "surface-strong": "#3e3931", + "surface-raised-stronger-non-alpha": "#34312b", + "input-base": "#302d27", + "input-hover": "#37332c", + "button-secondary-base": "#34312b", + "button-secondary-hover": "#3e3931", + "border-weaker-base": "#f4eee50a", + "border-weak-base": "#f4eee514", + "border-weak-hover": "#f4eee522", + "border-base": "#f4eee52e", + "border-hover": "#f4eee53d", + "border-strong-base": "#f4eee54d", + "text-base": "#e3ded5", + "text-weak": "#c0b8ad", + "text-weaker": "#b0a79c", + "text-strong": "#faf6ef", } as const const luminance = (color: string) => { @@ -120,12 +127,39 @@ const darkSemantics = [ "syntax-info", ] as const +const darkStructuralTokens = [ + "background-base", + "background-weak", + "background-strong", + "background-stronger", + "surface-inset-base", + "surface-inset-base-hover", + "surface-inset-strong", + "surface-inset-strong-hover", + "surface-float-base", + "surface-float-base-hover", + "surface-raised-strong", + "surface-raised-strong-hover", + "surface-raised-stronger", + "surface-raised-stronger-hover", + "surface-strong", + "surface-raised-stronger-non-alpha", + "input-base", + "input-hover", + "input-active", + "input-selected", + "input-focus", + "input-disabled", + "button-secondary-base", + "button-secondary-hover", +] as const + describe("OpenScience default theme", () => { - test("uses canonical warm paper, cool-neutral dark, and rust anchors", () => { + test("uses canonical warm paper, warm-charcoal dark, and rust anchors", () => { expect(openscience.light.overrides["background-base"]).toBe("#f7f4ed") expect(openscience.light.overrides["text-strong"]).toBe("#241f1a") expect(openscience.light.overrides["surface-brand-base"]).toBe("#b85c3b") - expect(openscience.dark.seeds.neutral).toBe("#292a28") + expect(openscience.dark.seeds.neutral).toBe("#5f574d") expect(openscience.dark.overrides["surface-brand-base"]).toBe("#d48765") for (const entry of Object.entries(darkPalette)) { @@ -135,6 +169,25 @@ describe("OpenScience default theme", () => { } }) + test("keeps every dark structural surface out of the near-black range", () => { + for (const token of darkStructuralTokens) { + const color = resolved.dark[token] + expect(color).toMatch(/^#[0-9a-f]{6}$/i) + expect(color.toLowerCase()).not.toBe("#000000") + expect(luminance(color)).toBeGreaterThanOrEqual(0.013) + expect(fallback.dark[token]).toBe(color) + } + }) + + test("keeps dark body, supporting text, and brand labels readable", () => { + expect(contrast(resolved.dark["text-base"], resolved.dark["background-base"])).toBeGreaterThanOrEqual(7) + expect(contrast(resolved.dark["text-weak"], resolved.dark["background-base"])).toBeGreaterThanOrEqual(4.5) + expect(contrast(resolved.dark["text-weaker"], resolved.dark["background-base"])).toBeGreaterThanOrEqual(4.5) + expect(contrast(resolved.dark["text-on-brand-base"], resolved.dark["surface-brand-base"])).toBeGreaterThanOrEqual( + 4.5, + ) + }) + test("does not change alternate themes", () => { expect(ayu.light.overrides["background-base"]).toBe("#fdfaf4") expect(ayu.dark.overrides["background-base"]).toBe("#0f1419") @@ -155,4 +208,10 @@ describe("OpenScience default theme", () => { expect(fallback.dark[token]).toBe(resolved.dark[token]) } }) + + test("keeps every explicit dark override identical before and after JavaScript loads", () => { + for (const token of Object.keys(darkOverrides)) { + expect(fallback.dark[token]).toBe(resolved.dark[token]) + } + }) }) diff --git a/frontend/ui/src/theme/themes/openscience.json b/frontend/ui/src/theme/themes/openscience.json index c273d552..8a5e2cc0 100644 --- a/frontend/ui/src/theme/themes/openscience.json +++ b/frontend/ui/src/theme/themes/openscience.json @@ -71,69 +71,159 @@ }, "dark": { "seeds": { - "neutral": "#292a28", + "neutral": "#5f574d", "primary": "#d48765", "success": "#79b58d", "warning": "#d4a557", "error": "#df7b68", - "info": "#8fb4d0", + "info": "#a99bbc", "interactive": "#d48765", "diffAdd": "#79b58d", "diffDelete": "#df7b68" }, "overrides": { - "background-base": "#1c1d1c", - "background-weak": "#202120", - "background-strong": "#181918", - "background-stronger": "#141514", - "surface-raised-strong": "#252625", - "surface-raised-strong-hover": "#2a2b2a", - "surface-raised-stronger": "#2a2b2a", - "surface-raised-stronger-hover": "#30312f", - "surface-strong": "#2d2e2c", - "surface-raised-stronger-non-alpha": "#252625", - "border-weaker-base": "#f0efeb08", - "border-weak-base": "#f0efeb12", - "border-weak-hover": "#f0efeb1d", - "border-base": "#f0efeb27", - "border-hover": "#f0efeb34", - "border-strong-base": "#f0efeb43", - "text-base": "#deddd8", - "text-weak": "#b0afa9", - "text-weaker": "#85847f", - "text-strong": "#f0efeb", - "text-interactive-base": "#e09a78", - "text-on-brand-base": "#1e1e1b", - "text-on-brand-weak": "#1e1e1b", - "text-on-brand-weaker": "#1e1e1b", - "text-on-brand-strong": "#1e1e1b", - "icon-on-brand-base": "#1e1e1b", - "icon-on-brand-hover": "#1e1e1b", - "icon-on-brand-selected": "#1e1e1b", + "background-base": "#26241f", + "background-weak": "#2d2a24", + "background-strong": "#2a2822", + "background-stronger": "#302d27", + "surface-base": "#fffaf208", + "base": "#fffaf208", + "surface-base-hover": "#fffaf20f", + "surface-base-active": "#fffaf216", + "surface-base-interactive-active": "#d4876540", + "base2": "#fffaf208", + "base3": "#fffaf208", + "surface-inset-base": "#23211d", + "surface-inset-base-hover": "#27241f", + "surface-inset-strong": "#201e1a", + "surface-inset-strong-hover": "#24211d", + "surface-raised-base": "#fffaf208", + "surface-float-base": "#34312b", + "surface-float-base-hover": "#3e3931", + "surface-raised-base-hover": "#fffaf20f", + "surface-raised-base-active": "#fffaf216", + "surface-raised-strong": "#34312b", + "surface-raised-strong-hover": "#3a362f", + "surface-raised-stronger": "#38342d", + "surface-raised-stronger-hover": "#423d35", + "surface-weak": "#fffaf20a", + "surface-weaker": "#fffaf210", + "surface-strong": "#3e3931", + "surface-raised-stronger-non-alpha": "#34312b", + "surface-interactive-base": "#4a3027", + "surface-interactive-hover": "#5a382c", + "surface-interactive-weak": "#33261f", + "surface-interactive-weak-hover": "#3e2b23", + "surface-success-base": "#24362b", + "surface-success-weak": "#202d25", + "surface-success-strong": "#79b58d", + "surface-warning-base": "#3b3020", + "surface-warning-weak": "#30291f", + "surface-warning-strong": "#d4a557", + "surface-critical-base": "#412b25", + "surface-critical-weak": "#352520", + "surface-critical-strong": "#df7b68", + "surface-info-base": "#322c39", + "surface-info-weak": "#2b2731", + "surface-info-strong": "#a99bbc", + "surface-diff-unchanged-base": "#26241f", + "surface-diff-skip-base": "#2a2822", + "surface-diff-hidden-base": "#33261f", + "surface-diff-hidden-weak": "#2e251f", + "surface-diff-hidden-weaker": "#3a2922", + "surface-diff-hidden-strong": "#5a382c", + "surface-diff-add-base": "#24362b", + "surface-diff-add-weak": "#2a3e31", + "surface-diff-add-weaker": "#24362b", + "surface-diff-add-strong": "#34513e", + "surface-diff-delete-base": "#412b25", + "surface-diff-delete-weak": "#4a3029", + "surface-diff-delete-weaker": "#412b25", + "surface-diff-delete-strong": "#5f392f", + "input-base": "#302d27", + "input-hover": "#37332c", + "input-active": "#3a2c25", + "input-selected": "#493328", + "input-focus": "#3a2c25", + "input-disabled": "#2c2924", + "button-secondary-base": "#34312b", + "button-secondary-hover": "#3e3931", + "button-ghost-hover": "#fffaf20a", + "button-ghost-hover2": "#fffaf210", + "border-weaker-base": "#f4eee50a", + "border-weaker-hover": "#f4eee512", + "border-weaker-active": "#f4eee51c", + "border-weaker-disabled": "#f4eee506", + "border-weak-base": "#f4eee514", + "border-weak-hover": "#f4eee522", + "border-base": "#f4eee52e", + "border-hover": "#f4eee53d", + "border-strong-base": "#f4eee54d", + "border-focus": "#d48765", + "border-selected": "#d48765", + "text-base": "#e3ded5", + "text-weak": "#c0b8ad", + "text-weaker": "#b0a79c", + "text-strong": "#faf6ef", + "text-interactive-base": "#e5a181", + "text-on-brand-base": "#231f1a", + "text-on-brand-weak": "#231f1a", + "text-on-brand-weaker": "#231f1a", + "text-on-brand-strong": "#231f1a", + "icon-on-brand-base": "#231f1a", + "icon-on-brand-hover": "#231f1a", + "icon-on-brand-selected": "#231f1a", + "icon-base": "#c0b8ad", + "icon-hover": "#e3ded5", + "icon-active": "#faf6ef", + "icon-selected": "#faf6ef", + "icon-disabled": "#756d63", + "icon-focus": "#faf6ef", + "icon-invert-base": "#231f1a", + "icon-weak-base": "#958b7f", + "icon-weak-hover": "#aaa196", + "icon-weak-active": "#c0b8ad", + "icon-weak-selected": "#e3ded5", + "icon-weak-disabled": "#6e665d", + "icon-weak-focus": "#c0b8ad", + "icon-brand-base": "#d48765", + "icon-interactive-base": "#d48765", + "icon-success-base": "#79b58d", + "icon-success-hover": "#8fc69f", + "icon-success-active": "#a7d6b5", + "icon-warning-base": "#d4a557", + "icon-warning-hover": "#e0b66f", + "icon-warning-active": "#ecc985", + "icon-critical-base": "#df7b68", + "icon-critical-hover": "#ed927f", + "icon-critical-active": "#f8ad9c", + "icon-info-base": "#a99bbc", + "icon-info-hover": "#bbaeca", + "icon-info-active": "#cfc3da", "surface-brand-base": "#d48765", - "surface-brand-hover": "#e09a78", + "surface-brand-hover": "#e5a181", "syntax-string": "#79b58d", "syntax-keyword": "#e39a77", "syntax-primitive": "#d4a557", - "syntax-operator": "#b5b2ab", + "syntax-operator": "#c0b8ad", "syntax-property": "#d48765", - "syntax-type": "#8fb4d0", + "syntax-type": "#b7a8c4", "syntax-constant": "#c89468", - "syntax-info": "#8fb4d0", - "markdown-heading": "#e09a78", - "markdown-text": "#e2e0da", + "syntax-info": "#b7a8c4", + "markdown-heading": "#e5a181", + "markdown-text": "#e3ded5", "markdown-link": "#d48765", - "markdown-link-text": "#8fb4d0", + "markdown-link-text": "#b7a8c4", "markdown-code": "#e39a77", "markdown-block-quote": "#d4a557", "markdown-emph": "#d4a557", "markdown-strong": "#e39a77", - "markdown-horizontal-rule": "#484845", + "markdown-horizontal-rule": "#5c554b", "markdown-list-item": "#d48765", - "markdown-list-enumeration": "#8fb4d0", + "markdown-list-enumeration": "#b7a8c4", "markdown-image": "#d48765", - "markdown-image-text": "#8fb4d0", - "markdown-code-block": "#e2e0da" + "markdown-image-text": "#b7a8c4", + "markdown-code-block": "#e3ded5" } } } diff --git a/frontend/workspace/e2e/home-projects.spec.ts b/frontend/workspace/e2e/home-projects.spec.ts index 440f6701..57b03ce2 100644 --- a/frontend/workspace/e2e/home-projects.spec.ts +++ b/frontend/workspace/e2e/home-projects.spec.ts @@ -16,8 +16,7 @@ test("home project search filters the recent list and clears back to it", async await expect(page.getByText("No matching projects", { exact: true })).toBeVisible() await expect(card).toHaveCount(0) - // Two "Clear search" affordances exist while the empty state shows (the - // search-bar icon and the empty-state button); either restores the list. + // Clearing from either the search field or the no-results recovery restores the list. await page.getByRole("button", { name: "Clear search", exact: true }).first().click() await expect(card).toBeVisible() }) @@ -32,7 +31,7 @@ test("existing folder import remains available through the in-app picker", async await expect(location).toBeVisible() await location.fill(directory) await location.press("Enter") - await page.getByRole("button", { name: "use this folder", exact: true }).click() + await page.getByRole("button", { name: "Use this folder", exact: true }).click() await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) await expect(page.locator(promptSelector)).toBeVisible() diff --git a/frontend/workspace/e2e/home.spec.ts b/frontend/workspace/e2e/home.spec.ts index cb91b5be..bb5b42d4 100644 --- a/frontend/workspace/e2e/home.spec.ts +++ b/frontend/workspace/e2e/home.spec.ts @@ -23,11 +23,15 @@ test("server picker dialog opens from home", async ({ page }) => { test("keyboard help opens and closes accessibly", async ({ page }) => { await page.goto("/") await expect(page.getByRole("button", { name: serverName })).toBeVisible() + const trigger = page.getByRole("button", { name: "Settings", exact: true }) + await trigger.focus() await page.keyboard.press("Shift+Slash") - const dialog = page.getByRole("dialog", { name: "keyboard shortcuts" }) + const dialog = page.getByRole("dialog", { name: "Keyboard shortcuts" }) await expect(dialog).toBeVisible() + await expect(dialog.getByRole("button", { name: "Close keyboard shortcuts" })).toBeFocused() await page.keyboard.press("Escape") await expect(dialog).toHaveCount(0) + await expect(trigger).toBeFocused() }) diff --git a/frontend/workspace/e2e/palette.spec.ts b/frontend/workspace/e2e/palette.spec.ts index 61af58f4..49064dbd 100644 --- a/frontend/workspace/e2e/palette.spec.ts +++ b/frontend/workspace/e2e/palette.spec.ts @@ -4,15 +4,17 @@ import { promptSelector } from "./utils" test("project search stays centered, local, and available from the composer", async ({ page, gotoSession }) => { await gotoSession() - await page.getByRole("button", { name: "Search project", exact: true }).click() + const trigger = page.getByRole("button", { name: "Search this project", exact: true }) + await trigger.click() - const dialog = page.getByRole("dialog", { name: "command palette" }) - const search = dialog.getByRole("textbox", { name: "Search this project" }) + const dialog = page.getByRole("dialog", { name: "Command palette" }) + const search = dialog.getByRole("combobox", { name: "Search this project" }) await expect(dialog).toBeVisible() - await expect(search).toBeVisible() - await expect(dialog.getByRole("button", { name: /Open project files/ })).toBeVisible() - await expect(dialog.getByText("projects", { exact: true })).toHaveCount(0) - await expect(dialog.getByRole("button", { name: /Settings/ })).toHaveCount(0) + await expect(search).toBeFocused() + await expect(dialog.getByRole("option", { name: /Open project files/ })).toBeVisible() + await expect(dialog.getByRole("option", { name: /Open settings/ })).toBeVisible() + await expect(dialog.getByRole("group", { name: "Projects" })).toHaveCount(0) + await expect(dialog.locator('[role="option"][aria-selected="true"]')).toHaveCount(1) const box = await dialog.boundingBox() const viewport = page.viewportSize() @@ -21,11 +23,35 @@ test("project search stays centered, local, and available from the composer", as // Account for scrollbar and subpixel differences in the packaged browser. // The old right-anchored panel was hundreds of pixels off center. expect(Math.abs((box?.x ?? 0) + (box?.width ?? 0) / 2 - (viewport?.width ?? 0) / 2)).toBeLessThan(12) + expect(box?.y ?? -1).toBeGreaterThanOrEqual(0) + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual((viewport?.height ?? 0) + 1) + + const layout = await dialog.evaluate((node) => ({ + clientWidth: node.clientWidth, + scrollWidth: node.scrollWidth, + })) + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth + 1) + await expect(dialog.locator(".command-palette__results-shell")).toHaveCSS("overflow-y", "auto") await page.keyboard.press("Escape") await expect(search).toHaveCount(0) + await expect(trigger).toBeFocused() - await page.locator(promptSelector).click() + const composer = page.locator(promptSelector) + await composer.click() await page.keyboard.press("ControlOrMeta+K") await expect(dialog).toBeVisible() + await expect(search).toBeFocused() + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) + await expect(composer).toBeFocused() + + await page.keyboard.press("ControlOrMeta+K") + await search.fill("settings") + await expect(dialog.getByRole("option", { name: /Open settings/ })).toBeVisible() + await page.keyboard.press("Enter") + + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("button", { name: "Close", exact: true })).toBeVisible() }) diff --git a/frontend/workspace/e2e/research-launchpad.spec.ts b/frontend/workspace/e2e/research-launchpad.spec.ts deleted file mode 100644 index 1c48a0ab..00000000 --- a/frontend/workspace/e2e/research-launchpad.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from "./fixtures" - -test("keeps the new-session canvas completely blank", async ({ page, gotoSession }) => { - await gotoSession() - - const launchpad = page.locator('[data-component="research-launchpad"]') - await expect(launchpad).toBeVisible() - await expect(launchpad).toHaveAttribute("aria-label", "New research session") - await expect(launchpad.getByRole("button")).toHaveCount(0) - await expect(launchpad.getByRole("heading")).toHaveCount(0) - await expect(launchpad.locator("[data-workflow]")).toHaveCount(0) -}) diff --git a/frontend/workspace/e2e/server-recovery.spec.ts b/frontend/workspace/e2e/server-recovery.spec.ts index f6a410d3..fa68f411 100644 --- a/frontend/workspace/e2e/server-recovery.spec.ts +++ b/frontend/workspace/e2e/server-recovery.spec.ts @@ -22,7 +22,7 @@ test("retries a failed server health check immediately and clears the recovery b await expect(recovery).toBeVisible() available = true - await recovery.getByRole("button", { name: "retry now" }).click() + await recovery.getByRole("button", { name: "Retry Now", exact: true }).click() await expect(recovery).toHaveCount(0) }) diff --git a/frontend/workspace/e2e/session.spec.ts b/frontend/workspace/e2e/session.spec.ts index da03a7bb..68b9afef 100644 --- a/frontend/workspace/e2e/session.spec.ts +++ b/frontend/workspace/e2e/session.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures" -import { promptSelector, sessionTab } from "./utils" +import { promptSelector, sessionHeading } from "./utils" function isSessionResponse(response: import("@playwright/test").Response, method: string, sessionID?: string) { const path = new URL(response.url()).pathname.replace(/\/$/, "") @@ -26,6 +26,34 @@ test("can open an existing session and type into the prompt", async ({ page, sdk } }) +test("session heading rename persists through the real session API", async ({ page, sdk, gotoSession }) => { + const title = `e2e heading ${Date.now()}` + const renamed = `${title} refined` + const created = await sdk.session.create({ title }).then((response) => response.data) + if (!created?.id) throw new Error("Session create did not return an id") + + try { + await gotoSession(created.id) + + await sessionHeading(page, title).focus() + await sessionHeading(page, title).press("F2") + const editor = page.getByRole("textbox", { name: "Session name", exact: true }) + await expect(editor).toBeFocused() + await editor.fill(renamed) + const responsePromise = page.waitForResponse((response) => isSessionResponse(response, "PATCH", created.id)) + await editor.press("Enter") + expect((await responsePromise).ok()).toBe(true) + + await expect(sessionHeading(page, renamed)).toBeVisible() + await expect(page.getByRole("complementary").getByRole("button", { name: renamed, exact: true })).toBeVisible() + await expect + .poll(async () => (await sdk.session.list()).data?.find((session) => session.id === created.id)?.title) + .toBe(renamed) + } finally { + await sdk.session.delete({ sessionID: created.id }).catch(() => undefined) + } +}) + test("delegation and specialist controls persist without changing the model", async ({ page, openSession }) => { await openSession() @@ -76,7 +104,7 @@ test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, const newResearch = page.getByRole("button", { name: "New research", exact: true }) const sidebar = page.getByRole("complementary").filter({ has: newResearch }) - const rows = sidebar.locator('div[role="button"]') + const rows = sidebar.locator(".session-sidebar__session") await expect(sidebar).toBeVisible() const baselineCount = await rows.count() @@ -106,15 +134,16 @@ test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, const renamedRow = rows.filter({ hasText: renamedTitle }) await expect(renamedRow).toHaveCount(1) await expect(rows.filter({ hasText: created.title })).toHaveCount(0) - await expect(sessionTab(page, renamedTitle)).toHaveAttribute("aria-selected", "true") + await expect(sessionHeading(page, renamedTitle)).toBeVisible() + await expect(renamedRow.getByRole("button", { name: renamedTitle, exact: true })).toBeFocused() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:\\?|#|$)`)) await renamedRow.hover() - const actions = renamedRow.getByRole("button", { name: "Session actions", exact: true }) + const actions = renamedRow.getByRole("button", { name: `Session actions for ${renamedTitle}`, exact: true }) await expect(actions).toBeVisible() await actions.click() await renamedRow.getByRole("menuitem", { name: "Delete", exact: true }).click() - const deleteButton = page.getByRole("button", { name: "delete session", exact: true }) + const deleteButton = page.getByRole("button", { name: "Delete session", exact: true }) await expect(deleteButton).toBeVisible() const deleteResponsePromise = page.waitForResponse((response) => isSessionResponse(response, "DELETE", sessionID)) await deleteButton.click() @@ -123,7 +152,7 @@ test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, await expect(renamedRow).toHaveCount(0) await expect(rows).toHaveCount(baselineCount) - await expect(sessionTab(page, renamedTitle)).toHaveCount(0) + await expect(sessionHeading(page, renamedTitle)).toHaveCount(0) if (baselineCount === 0) { await expect(page).toHaveURL(new RegExp(`/${slug}/session/new(?:\\?|#|$)`)) } else { diff --git a/frontend/workspace/e2e/sidebar-session-links.spec.ts b/frontend/workspace/e2e/sidebar-session-links.spec.ts index e4519bc8..7a8e3ecf 100644 --- a/frontend/workspace/e2e/sidebar-session-links.spec.ts +++ b/frontend/workspace/e2e/sidebar-session-links.spec.ts @@ -1,7 +1,12 @@ import { test, expect } from "./fixtures" -import { promptSelector, sessionTab } from "./utils" +import { promptSelector, sessionHeading } from "./utils" -test("sidebar session rows navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { +test("sidebar rows open header session tabs that switch by pointer or keyboard", async ({ + page, + slug, + sdk, + gotoSession, +}) => { const stamp = Date.now() const oneTitle = `e2e sidebar nav 1 ${stamp}` const twoTitle = `e2e sidebar nav 2 ${stamp}` @@ -15,14 +20,35 @@ test("sidebar session rows navigate to the selected session", async ({ page, slu await gotoSession(one.id) const sidebar = page.getByRole("complementary").filter({ has: page.getByRole("button", { name: "New research" }) }) - const target = sidebar.locator('[role="button"]').filter({ hasText: twoTitle }) + const target = sidebar.getByRole("button", { name: twoTitle, exact: true }) await expect(target).toBeVisible() await target.scrollIntoViewIfNeeded() await target.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + const tabs = page.locator(".workspace-tabs").getByRole("tablist") + const active = tabs.getByRole("tab", { name: new RegExp(`^${twoTitle}`) }) + await expect(active).toHaveAttribute("aria-selected", "true") + await expect(active).toHaveAttribute("tabindex", "0") await expect(page.locator(promptSelector)).toBeVisible() - await expect(sessionTab(page, twoTitle)).toHaveAttribute("aria-selected", "true") + await expect(sessionHeading(page, twoTitle)).toBeVisible() + + const ordered = await tabs.getByRole("tab").evaluateAll((items) => + items.map((item) => ({ + id: item.getAttribute("data-session-tab"), + selected: item.getAttribute("aria-selected") === "true", + })), + ) + const current = ordered.findIndex((item) => item.selected) + const next = ordered[(current + 1) % ordered.length]?.id + if (!next) throw new Error("Session tabs did not expose a keyboard target") + + await active.focus() + await active.press("ArrowRight") + const nextTab = tabs.locator(`[role="tab"][data-session-tab="${next}"]`) + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${next}(?:\\?|#|$)`)) + await expect(nextTab).toHaveAttribute("aria-selected", "true") + await expect(nextTab).toBeFocused() } finally { await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) diff --git a/frontend/workspace/e2e/sidebar.spec.ts b/frontend/workspace/e2e/sidebar.spec.ts index 208285ab..efe406c1 100644 --- a/frontend/workspace/e2e/sidebar.spec.ts +++ b/frontend/workspace/e2e/sidebar.spec.ts @@ -13,12 +13,13 @@ test("sidebar keeps recent sessions visible without a permanent search field", a try { await gotoSession(one.id) - const sidebar = page.getByRole("complementary").filter({ has: page.getByRole("button", { name: "New research" }) }) + const sidebar = page.locator(".session-sidebar") await expect(sidebar).toBeVisible() await expect(sidebar.getByRole("button", { name: "New research" })).toBeVisible() await expect(sidebar.getByPlaceholder("Search sessions")).toHaveCount(0) - await expect(sidebar.locator('[role="button"]').filter({ hasText: oneTitle })).toBeVisible() - await expect(sidebar.locator('[role="button"]').filter({ hasText: twoTitle })).toBeVisible() + await expect(sidebar.getByRole("tablist")).toHaveCount(0) + await expect(sidebar.getByRole("button", { name: oneTitle, exact: true })).toBeVisible() + await expect(sidebar.getByRole("button", { name: twoTitle, exact: true })).toBeVisible() } finally { await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) @@ -31,7 +32,7 @@ test.describe("mobile workspace", () => { test("sessions open as a drawer instead of squeezing the active pane", async ({ page, gotoSession }) => { await gotoSession() - const sidebar = page.getByRole("complementary").filter({ has: page.getByRole("button", { name: "New research" }) }) + const sidebar = page.locator(".session-sidebar") await expect(sidebar).toHaveAttribute("data-mobile-open", "false") await page.getByRole("button", { name: "Show sessions" }).click() @@ -41,7 +42,7 @@ test.describe("mobile workspace", () => { // The backdrop button spans the full viewport behind the drawer, so a // centered click lands on the drawer that covers it. Dispatch the click on // the backdrop element directly to exercise its close handler. - await page.getByRole("button", { name: "close sessions" }).dispatchEvent("click") + await page.getByRole("button", { name: "Close sessions" }).dispatchEvent("click") await expect(sidebar).toHaveAttribute("data-mobile-open", "false") }) }) diff --git a/frontend/workspace/e2e/titlebar-history.spec.ts b/frontend/workspace/e2e/titlebar-history.spec.ts index a3113891..2c9a5c63 100644 --- a/frontend/workspace/e2e/titlebar-history.spec.ts +++ b/frontend/workspace/e2e/titlebar-history.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures" -import { promptSelector, sessionTab } from "./utils" +import { promptSelector, sessionHeading } from "./utils" test("browser back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { await page.setViewportSize({ width: 1400, height: 800 }) @@ -17,26 +17,26 @@ test("browser back/forward navigates between sessions", async ({ page, slug, sdk await gotoSession(one.id) const sidebar = page.getByRole("complementary").filter({ has: page.getByRole("button", { name: "New research" }) }) - const target = sidebar.locator('[role="button"]').filter({ hasText: twoTitle }) + const target = sidebar.getByRole("button", { name: twoTitle }) await expect(target).toBeVisible() await target.scrollIntoViewIfNeeded() await target.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() - await expect(sessionTab(page, twoTitle)).toHaveAttribute("aria-selected", "true") + await expect(sessionHeading(page, twoTitle)).toBeVisible() await page.goBack() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() - await expect(sessionTab(page, oneTitle)).toHaveAttribute("aria-selected", "true") + await expect(sessionHeading(page, oneTitle)).toBeVisible() await page.goForward() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() - await expect(sessionTab(page, twoTitle)).toHaveAttribute("aria-selected", "true") + await expect(sessionHeading(page, twoTitle)).toBeVisible() } finally { await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) diff --git a/frontend/workspace/e2e/utils.ts b/frontend/workspace/e2e/utils.ts index c54c2077..3b7eb388 100644 --- a/frontend/workspace/e2e/utils.ts +++ b/frontend/workspace/e2e/utils.ts @@ -94,13 +94,9 @@ export function fileTab(page: Page, title: string) { return page.locator(`.files-tabs [role="tab"][title="${title}"]`) } -/** - * A session tab in the workspace tab strip, matched by its title text. The - * tab's accessible name also folds in its close button ("Close tab"), - * so match on visible text rather than the computed role name. - */ -export function sessionTab(page: Page, title: string) { - return page.locator('.workspace-tabs [role="tab"]').filter({ hasText: title }) +/** The visible active-session title now lives in the chat header tab strip. */ +export function sessionHeading(page: Page, title: string) { + return page.locator(".workspace-tabs").getByRole("tab", { name: prefix(title) }) } /** diff --git a/frontend/workspace/e2e/workspace-consistency.spec.ts b/frontend/workspace/e2e/workspace-consistency.spec.ts index 5833c25a..32055f8a 100644 --- a/frontend/workspace/e2e/workspace-consistency.spec.ts +++ b/frontend/workspace/e2e/workspace-consistency.spec.ts @@ -71,7 +71,7 @@ test("keeps destructive confirmations on the workspace type scale", async ({ pag await row.locator(".session-sidebar__session-menu-button").click() await page.getByRole("menuitem", { name: "Delete", exact: true }).click() - const confirmation = page.locator('[data-component="dialog-lite"]') + const confirmation = page.getByRole("alertdialog") const title = confirmation.getByText("Delete this session?", { exact: true }) await expect(title).toBeVisible() await expect(title).toHaveCSS("font-family", /Inter/) @@ -81,6 +81,7 @@ test("keeps destructive confirmations on the workspace type scale", async ({ pag const cancel = confirmation.getByRole("button", { name: "Cancel", exact: true }) const remove = confirmation.getByRole("button", { name: "Delete session", exact: true }) + await expect(cancel).toBeFocused() await expect(cancel).toHaveCSS("font-family", /Inter/) await expect(remove).toHaveCSS("font-family", /Inter/) await expect(cancel).toHaveCSS("font-size", "12px") diff --git a/frontend/workspace/index.html b/frontend/workspace/index.html index b068aa82..46e959a1 100644 --- a/frontend/workspace/index.html +++ b/frontend/workspace/index.html @@ -9,8 +9,7 @@ <link rel="shortcut icon" href="/favicon-v4.ico" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v4.png" /> <link rel="manifest" href="/site.webmanifest" /> - <meta name="theme-color" content="#f2f2f0" /> - <meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" /> + <meta name="theme-color" content="#26241f" /> <meta property="og:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" /> <!-- All fonts are self-hosted: Computer Modern (UI, see thesis.css) and the diff --git a/frontend/workspace/public/openscience-theme-preload.js b/frontend/workspace/public/openscience-theme-preload.js index 1b4f844c..5b2dbec2 100644 --- a/frontend/workspace/public/openscience-theme-preload.js +++ b/frontend/workspace/public/openscience-theme-preload.js @@ -1,20 +1,29 @@ ;(function () { - var themeId = localStorage.getItem("openscience-theme-id") - if (!themeId) return + // OpenScience currently ships one canonical product theme. Pin it before the + // application mounts so an older saved gallery theme cannot flash or leave + // the workspace on an incompatible near-black token set. + var themeId = "openscience" + localStorage.setItem("openscience-theme-id", themeId) - var scheme = localStorage.getItem("openscience-color-scheme") || "system" + // Respect the explicit display mode, or mirror the OS when set to System. + // Validate the stored value so an obsolete preference cannot strand startup. + // Dark is the first-run default. Existing System and Light choices remain + // authoritative and are never overwritten here. + var scheme = localStorage.getItem("openscience-color-scheme") || "dark" + if (scheme !== "system" && scheme !== "light" && scheme !== "dark") scheme = "system" var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) var mode = isDark ? "dark" : "light" document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode - if (themeId === "openscience-1") return - - // Keep in lockstep with STORAGE_KEYS.THEME_CSS_* in ui/src/theme/context.tsx — - // the cache is keyed by mode only, not by theme id. - var css = localStorage.getItem("openscience-theme-css-" + mode) + // Keep in lockstep with themeCssKey() in ui/src/theme/context.tsx. Theme id + // is part of the cache key so CSS from a previously selected theme can never + // be replayed during startup. + var css = localStorage.getItem("openscience-theme-css-" + themeId + "-" + mode) if (css) { + var background = css.match(/--background-base:\s*([^;]+);/) + if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute("content", background[1].trim()) var style = document.createElement("style") style.id = "openscience-theme-preload" style.textContent = diff --git a/frontend/workspace/script/release-version.test.ts b/frontend/workspace/script/release-version.test.ts index c1c18b80..e9c1998c 100644 --- a/frontend/workspace/script/release-version.test.ts +++ b/frontend/workspace/script/release-version.test.ts @@ -36,4 +36,4 @@ test("production web assets embed the explicit release version", async () => { } finally { await fs.rm(output, { recursive: true, force: true }) } -}) +}, 30_000) diff --git a/frontend/workspace/src/app-provider.test.ts b/frontend/workspace/src/app-provider.test.ts index e70c4296..eb53bf0c 100644 --- a/frontend/workspace/src/app-provider.test.ts +++ b/frontend/workspace/src/app-provider.test.ts @@ -8,3 +8,9 @@ test("the launch screen stays in the global sync provider owner tree", () => { expect(source.indexOf("<GlobalSyncProvider>")).toBeLessThan(source.indexOf("<Router")) expect(source.indexOf("<Router")).toBeLessThan(source.indexOf("<Home />")) }) + +test("unmatched routes resolve to a useful recovery surface", () => { + expect(source).toContain('path="*404" component={NotFound}') + expect(source).toContain('aria-labelledby="not-found-title"') + expect(source).toContain("Back to Projects") +}) diff --git a/frontend/workspace/src/app.css b/frontend/workspace/src/app.css new file mode 100644 index 00000000..7320247e --- /dev/null +++ b/frontend/workspace/src/app.css @@ -0,0 +1,53 @@ +.app-not-found { + width: min(100%, 640px); + min-height: 100%; + margin: 0 auto; + display: flex; + align-items: start; + justify-content: center; + flex-direction: column; + gap: 12px; + padding: clamp(48px, 10vh, 96px) 28px; + color: var(--text-strong); +} + +.app-not-found__eyebrow { + font-size: 12px; + font-weight: var(--font-weight-emphasis); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); +} + +.app-not-found h1 { + margin: 0; + font-size: clamp(32px, 6vw, 48px); + font-weight: var(--font-weight-emphasis); + letter-spacing: -0.04em; +} + +.app-not-found p { + max-width: 48ch; + margin: 0; + color: var(--text-weak); + font-size: 15px; + line-height: 1.6; + text-wrap: pretty; +} + +.app-not-found__action { + min-height: 44px; + display: inline-flex; + align-items: center; + margin-top: 8px; + padding: 0 16px; + border-radius: var(--radius-md); + background: var(--button-primary-base); + color: var(--icon-invert-base); + font-size: 14px; + font-weight: var(--font-weight-emphasis); + text-decoration: none; +} + +.app-not-found__action:hover { + background: var(--icon-strong-hover); +} diff --git a/frontend/workspace/src/app.tsx b/frontend/workspace/src/app.tsx index 69edcf88..79d899e7 100644 --- a/frontend/workspace/src/app.tsx +++ b/frontend/workspace/src/app.tsx @@ -1,6 +1,6 @@ import "@/index.css" -import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js" -import { Router, Route, Navigate } from "@solidjs/router" +import { ErrorBoundary, Show, Suspense, onMount, type ParentProps } from "solid-js" +import { A, Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { I18nProvider } from "@synsci/ui/context" import { ThemeProvider } from "@synsci/ui/theme" @@ -10,9 +10,7 @@ import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" -import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" -import { FileProvider } from "@/context/file" import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" import { ModelsProvider } from "@/context/models" @@ -26,17 +24,30 @@ import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" import { URLS } from "@/config/urls" import { resolveDefaultServerUrl } from "@/config/server-url" -import { Suspense } from "solid-js" import { AsciiSpinner } from "@/atlas/shared/AsciiSpinner" import Home from "@/pages/home" +import { Session } from "@/pages/session-loader" +import { DEFAULT_PANEL, preloadPanel } from "@/components/settings/registry" +import { StartupUpdateCheck } from "@/components/settings/startup-update" +import "./app.css" -const Session = lazy(() => import("@/pages/session-shell")) const Loading = () => ( <div class="size-full" style={{ display: "flex", "align-items": "center", "justify-content": "center" }}> <AsciiSpinner label="loading…" color="var(--color-text-faint)" /> </div> ) +const NotFound = () => ( + <main class="app-not-found" aria-labelledby="not-found-title"> + <span class="app-not-found__eyebrow">404</span> + <h1 id="not-found-title">Page Not Found</h1> + <p>This address does not match a project, session, or OpenScience workspace.</p> + <A class="app-not-found__action" href="/"> + Back to Projects + </A> + </main> +) + function UiI18nBridge(props: ParentProps) { const language = useLanguage() return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> @@ -52,7 +63,7 @@ declare global { export function AppBaseProviders(props: ParentProps) { return ( <MetaProvider> - <ThemeProvider> + <ThemeProvider lockedTheme="openscience"> <LanguageProvider> <UiI18nBridge> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> @@ -77,6 +88,11 @@ function ServerKey(props: ParentProps) { export function AppInterface(props: { defaultUrl?: string }) { const platform = usePlatform() + // Warm the default Customize panel from the mounted app lifecycle. Keeping + // this out of module evaluation prevents detached consumers (including + // isolated renderers) from owning an untracked lazy import. + onMount(() => void preloadPanel(DEFAULT_PANEL).catch(() => undefined)) + const stored = (() => { if (platform.platform !== "web") return const result = platform.getDefaultServerUrl?.() @@ -115,6 +131,7 @@ export function AppInterface(props: { defaultUrl?: string }) { <Router root={(props) => ( <SettingsProvider> + <StartupUpdateCheck /> <PermissionProvider> <LayoutProvider> <NotificationProvider> @@ -145,21 +162,18 @@ export function AppInterface(props: { defaultUrl?: string }) { path="/session/:id?" component={(p) => ( <Show when={p.params.id ?? "new"}> - <TerminalProvider> - <FileProvider> - <PromptProvider> - <CommentsProvider> - <Suspense fallback={<Loading />}> - <Session /> - </Suspense> - </CommentsProvider> - </PromptProvider> - </FileProvider> - </TerminalProvider> + <PromptProvider> + <CommentsProvider> + <Suspense fallback={<Loading />}> + <Session /> + </Suspense> + </CommentsProvider> + </PromptProvider> </Show> )} /> </Route> + <Route path="*404" component={NotFound} /> </Router> </GlobalSyncProvider> </GlobalSDKProvider> diff --git a/frontend/workspace/src/artifacts/ArtifactInspector.tsx b/frontend/workspace/src/artifacts/ArtifactInspector.tsx index b2f039c5..da9e0741 100644 --- a/frontend/workspace/src/artifacts/ArtifactInspector.tsx +++ b/frontend/workspace/src/artifacts/ArtifactInspector.tsx @@ -53,6 +53,13 @@ const CONTROL_HEIGHT = "44px" const CONTROL_RADIUS = "14px" const GROUP_RADIUS = "18px" +function sentence(value: string) { + const text = value.replace(/[-_]+/g, " ").trim() + if (!text) return text + if (text.toLocaleLowerCase() === "ok") return "OK" + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}` +} + interface AnnotationMessage { id: string body: string @@ -165,16 +172,16 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( const copy = async () => { await navigator.clipboard?.writeText(props.context.path) - toast.success("copied", props.context.path) + toast.success("Copied", props.context.path) } const attach = () => { prompt.context.add({ type: "file", path: props.context.path }) - toast.success("added to context", props.context.name) + toast.success("Added to context", props.context.name) } const download = async () => { const response = await sdk.request("/file/raw", undefined, query(props.context.path)).catch(() => undefined) if (!response?.ok) { - toast.error("download failed", response ? `${response.status}` : "request failed") + toast.error("Download failed", response ? `${response.status}` : "Request failed") return } const object = URL.createObjectURL(await response.blob()) @@ -197,7 +204,7 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( ) .catch(() => undefined) if (!response?.ok) { - toast.error("annotation failed", response ? `${response.status}` : "request failed") + toast.error("Annotation failed", response ? `${response.status}` : "Request failed") return false } await api.refetch() @@ -233,8 +240,8 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( if (!response?.ok) { const payload = (await response?.json().catch(() => undefined)) as { error?: unknown } | undefined const detail = - typeof payload?.error === "string" ? payload.error : response ? `${response.status}` : "request failed" - toast.error("publication preflight failed", detail) + typeof payload?.error === "string" ? payload.error : response ? `${response.status}` : "Request failed" + toast.error("Publication preflight failed", detail) return false } await api.refetch() @@ -268,11 +275,11 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( if (!response?.ok) { const payload = (await response?.json().catch(() => undefined)) as { error?: unknown } | undefined const detail = - typeof payload?.error === "string" ? payload.error : response ? `${response.status}` : "request failed" - toast.error("could not record the fix", detail) + typeof payload?.error === "string" ? payload.error : response ? `${response.status}` : "Request failed" + toast.error("Could not record the fix", detail) return } - toast.success("finding marked addressed", note) + toast.success("Finding marked addressed", note) await api.refetch() } const runReview = () => @@ -342,6 +349,7 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( style={{ "font-family": FONT_SANS, "font-size": "18px", + "font-weight": "var(--font-weight-medium)", "line-height": 1.3, color: "var(--color-text)", overflow: "hidden", @@ -351,16 +359,16 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( > {props.context.name} </strong> - <span style={{ "font-family": FONT_MONO, "font-size": META_FONT_SIZE, color: "var(--color-text-faint)" }}> + <span style={{ "font-family": FONT_SANS, "font-size": META_FONT_SIZE, color: "var(--color-text-faint)" }}> {props.context.kind} · {props.context.format.toUpperCase()} </span> </div> - <button type="button" title="refresh file details" style={iconButton()} onClick={() => void api.refetch()}> + <button type="button" title="Refresh file details" style={iconButton()} onClick={() => void api.refetch()}> <IconRefresh size={13} /> </button> <Show when={props.onClose}> <button type="button" style={quietButton()} onClick={() => props.onClose?.()}> - close + Close </button> </Show> </header> @@ -374,13 +382,13 @@ export function ArtifactInspector(props: { context: ArtifactContext; onClose?: ( }} > <button type="button" style={actionButton(true)} onClick={attach}> - <IconFlask size={16} /> ask + <IconFlask size={16} /> Ask </button> <button type="button" style={actionButton()} onClick={() => void copy()}> - <IconCopy size={16} /> path + <IconCopy size={16} /> Path </button> <button type="button" style={actionButton()} onClick={() => void download()}> - <IconDownload size={16} /> download + <IconDownload size={16} /> Download </button> </div> @@ -556,14 +564,20 @@ function Review(props: { style={{ "font-family": FONT_MONO, "font-size": META_FONT_SIZE, - "text-transform": "uppercase", - "letter-spacing": "0.08em", + "letter-spacing": "normal", color: reviewColor(props.state.kind), }} > - {props.state.kind.replace("-", " ")} + {sentence(props.state.kind)} </span> - <strong style={{ "font-family": FONT_SANS, "font-size": SECTION_TITLE_SIZE, color: "var(--color-text)" }}> + <strong + style={{ + "font-family": FONT_SANS, + "font-size": SECTION_TITLE_SIZE, + "font-weight": "var(--font-weight-medium)", + color: "var(--color-text)", + }} + > {props.state.title} </strong> <p style={copyStyle()}>{props.state.detail}</p> @@ -732,7 +746,14 @@ function Review(props: { when={props.annotations.length} fallback={ <div style={{ ...card(), "justify-items": "start" }}> - <strong style={{ "font-family": FONT_SANS, "font-size": "18px", color: "var(--color-text)" }}> + <strong + style={{ + "font-family": FONT_SANS, + "font-size": "18px", + "font-weight": "var(--font-weight-medium)", + color: "var(--color-text)", + }} + > No annotations yet </strong> <p style={copyStyle()}>Add the first review note. Threads persist with this project and file.</p> @@ -762,8 +783,9 @@ function ReviewMetric(props: { > <strong style={{ - "font-family": FONT_MONO, + "font-family": FONT_SANS, "font-size": SECTION_TITLE_SIZE, + "font-weight": "var(--font-weight-emphasis)", color: props.tone ? findingColor(props.tone) : "var(--color-text)", }} > @@ -771,13 +793,12 @@ function ReviewMetric(props: { </strong> <span style={{ - "font-family": FONT_MONO, + "font-family": FONT_SANS, "font-size": "13px", - "text-transform": "uppercase", color: "var(--color-text-faint)", }} > - {props.label} + {sentence(props.label)} </span> </div> ) @@ -816,12 +837,18 @@ function ReviewFindingCard(props: { }} > <div style={{ display: "flex", "align-items": "center", gap: "8px", "flex-wrap": "wrap" }}> - <span style={findingBadge(props.finding.severity)}>{props.finding.severity}</span> - <span style={findingBadge()}>{props.finding.check}</span> - <span style={{ ...findingBadge(), "margin-left": "auto" }}>{props.finding.status}</span> + <span style={findingBadge(props.finding.severity)}>{sentence(props.finding.severity)}</span> + <span style={findingBadge()}>{sentence(props.finding.check)}</span> + <span style={{ ...findingBadge(), "margin-left": "auto" }}>{sentence(props.finding.status)}</span> </div> <strong - style={{ "font-family": FONT_SANS, "font-size": "18px", "line-height": 1.35, color: "var(--color-text)" }} + style={{ + "font-family": FONT_SANS, + "font-size": "18px", + "font-weight": "var(--font-weight-medium)", + "line-height": 1.35, + color: "var(--color-text)", + }} > {props.finding.title} </strong> @@ -836,7 +863,7 @@ function ReviewFindingCard(props: { style={{ "font-family": FONT_SANS, "font-size": META_FONT_SIZE, - "font-weight": 650, + "font-weight": "var(--font-weight-emphasis)", color: "var(--color-text)", }} > @@ -951,12 +978,12 @@ function ReviewerFindingCard(props: { }} > <div style={{ display: "flex", "align-items": "center", gap: "8px", "flex-wrap": "wrap" }}> - <span style={findingBadge(props.finding.severity)}>{props.finding.severity}</span> - <span style={findingBadge()}>{props.finding.verdict}</span> + <span style={findingBadge(props.finding.severity)}>{sentence(props.finding.severity)}</span> + <span style={findingBadge()}>{sentence(props.finding.verdict)}</span> <Show when={props.finding.status}> {(status) => ( <span data-chip="finding-status" style={{ ...reviewerStatusBadge(status()), "margin-left": "auto" }}> - {status()} + {sentence(status())} </span> )} </Show> @@ -1055,8 +1082,7 @@ function AnnotationThread(props: { color: props.annotation.status === "open" ? "var(--color-warning)" : "var(--color-success)", "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 650, - "text-transform": "capitalize", + "font-weight": "var(--font-weight-emphasis)", }} > {props.annotation.status === "open" ? "Open" : "Resolved"} @@ -1322,7 +1348,7 @@ function RunCard(props: { run: LineageRun }): JSX.Element { <Show when={props.run.status}> {(status) => ( <span data-run-status={status()} style={{ ...runBadge(status()), "margin-left": "auto" }}> - {status()} + {sentence(status())} </span> )} </Show> @@ -1512,7 +1538,7 @@ function Heading(props: { "font-family": FONT_SANS, "font-size": SECTION_TITLE_SIZE, "line-height": 1.3, - "font-weight": 650, + "font-weight": "var(--font-weight-emphasis)", color: "var(--color-text)", }} > @@ -1526,7 +1552,7 @@ function Fact(props: { label: string; value: string; mono?: boolean }): JSX.Elem return ( <div style={{ display: "grid", gap: "4px", "align-items": "start", padding: "2px 0 8px" }}> <span style={{ "font-family": FONT_SANS, "font-size": META_FONT_SIZE, color: "var(--color-text-faint)" }}> - {props.label} + {sentence(props.label)} </span> <span title={props.value} @@ -1581,7 +1607,7 @@ function actionButton(primary = false): JSX.CSSProperties { color: primary ? "var(--color-bg)" : "var(--color-text-muted)", "font-family": FONT_SANS, "font-size": "15px", - "font-weight": primary ? 650 : 500, + "font-weight": primary ? "var(--font-weight-medium)" : "var(--font-weight-regular)", } } @@ -1600,7 +1626,7 @@ function tabButton(active: boolean): JSX.CSSProperties { color: active ? "var(--color-text)" : "var(--color-text-muted)", "font-family": FONT_SANS, "font-size": "15px", - "font-weight": active ? 650 : 500, + "font-weight": active ? "var(--font-weight-medium)" : "var(--font-weight-regular)", "white-space": "nowrap", } } @@ -1633,6 +1659,7 @@ function quietButton(): JSX.CSSProperties { "border-radius": CONTROL_RADIUS, "font-family": FONT_SANS, "font-size": "15px", + "font-weight": "var(--font-weight-regular)", color: "var(--color-text-muted)", } } @@ -1660,11 +1687,10 @@ function runBadge(status: "ok" | "error"): JSX.CSSProperties { ? "var(--color-error-subtle, var(--color-bg))" : "var(--color-success-subtle, var(--color-bg))", color: status === "error" ? "var(--color-danger, var(--color-error))" : "var(--color-success)", - "font-family": FONT_MONO, + "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 650, - "text-transform": "uppercase", - "letter-spacing": "0.04em", + "font-weight": "var(--font-weight-emphasis)", + "letter-spacing": "normal", } } @@ -1680,11 +1706,10 @@ function reviewerStatusBadge(status: NonNullable<ReviewerFinding["status"]>): JS "border-radius": "999px", background: "var(--color-bg)", color, - "font-family": FONT_MONO, + "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 650, - "text-transform": "uppercase", - "letter-spacing": "0.04em", + "font-weight": "var(--font-weight-emphasis)", + "letter-spacing": "normal", } } @@ -1694,11 +1719,10 @@ function findingBadge(severity?: PublicationReviewFinding["severity"]): JSX.CSSP "border-radius": "999px", background: "var(--color-bg)", color: severity ? findingColor(severity) : "var(--color-text-muted)", - "font-family": FONT_MONO, + "font-family": FONT_SANS, "font-size": "13px", - "font-weight": severity ? 650 : 500, - "text-transform": "uppercase", - "letter-spacing": "0.04em", + "font-weight": severity ? "var(--font-weight-emphasis)" : "var(--font-weight-medium)", + "letter-spacing": "normal", } } diff --git a/frontend/workspace/src/artifacts/StoredArtifactView.tsx b/frontend/workspace/src/artifacts/StoredArtifactView.tsx index 16f147cc..d4035db9 100644 --- a/frontend/workspace/src/artifacts/StoredArtifactView.tsx +++ b/frontend/workspace/src/artifacts/StoredArtifactView.tsx @@ -37,6 +37,12 @@ const tabs: Array<{ id: Tab; label: string }> = [ { id: "review", label: "Review" }, ] +function sentence(value: string) { + const text = value.replace(/[-_]+/g, " ").trim() + if (!text) return text + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}` +} + function size(bytes: number) { if (bytes < 1024) return `${bytes} B` const units = ["KB", "MB", "GB"] @@ -469,8 +475,8 @@ export function StoredArtifactView(props: { artifact: StoredArtifact }): JSX.Ele <div style={findingHead()}> <strong style={findingTitle()}>{finding.claim}</strong> <span style={chip(finding.verdict === "refutes")}> - {finding.verdict === "refutes" ? finding.severity : "supported"} - {finding.status ? ` · ${finding.status}` : ""} + {sentence(finding.verdict === "refutes" ? finding.severity : "supported")} + {finding.status ? ` · ${sentence(finding.status)}` : ""} </span> </div> <p style={copy()}>{finding.issue}</p> @@ -548,7 +554,7 @@ function Preview(props: { function Fact(props: { label: string; value: string; mono?: boolean }): JSX.Element { return ( <div style={{ display: "grid", gap: "4px" }}> - <dt style={factLabel()}>{props.label}</dt> + <dt style={factLabel()}>{sentence(props.label)}</dt> <dd style={{ ...factValue(), "font-family": props.mono ? FONT_MONO : FONT_SANS }}>{props.value}</dd> </div> ) @@ -579,7 +585,7 @@ const title = (): JSX.CSSProperties => ({ "white-space": "nowrap", color: "var(--color-text)", "font-size": "13px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", }) const meta = (): JSX.CSSProperties => ({ display: "block", @@ -644,7 +650,7 @@ const field = (): JSX.CSSProperties => ({ gap: "5px", color: "var(--color-text-muted)", "font-size": "10px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", }) const input = (): JSX.CSSProperties => ({ width: "100%", @@ -675,7 +681,7 @@ const secondary = (): JSX.CSSProperties => ({ padding: "7px 9px", color: "var(--color-text)", "font-size": "11px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", background: "var(--color-bg-subtle)", border: "1px solid var(--color-border)", "border-radius": "7px", @@ -738,9 +744,8 @@ const facts = (): JSX.CSSProperties => ({ margin: 0, display: "grid", gap: "14px const factLabel = (): JSX.CSSProperties => ({ color: "var(--color-text-faint)", "font-size": "10px", - "font-weight": 600, - "text-transform": "uppercase", - "letter-spacing": "0.06em", + "font-weight": "var(--font-weight-emphasis)", + "letter-spacing": "normal", }) const factValue = (): JSX.CSSProperties => ({ margin: 0, @@ -791,9 +796,8 @@ const chip = (flagged: boolean): JSX.CSSProperties => ({ padding: "3px 6px", color: flagged ? "var(--color-danger, #b42318)" : "var(--color-text-muted)", "font-size": "9px", - "font-weight": 700, - "text-transform": "uppercase", - "letter-spacing": "0.04em", + "font-weight": "var(--font-weight-emphasis)", + "letter-spacing": "normal", background: "var(--color-bg-subtle)", border: "1px solid var(--color-border-subtle)", "border-radius": "999px", @@ -809,7 +813,7 @@ const primary = (): JSX.CSSProperties => ({ padding: "8px 11px", color: "var(--color-bg)", "font-size": "11px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", background: "var(--color-text)", border: 0, "border-radius": "7px", diff --git a/frontend/workspace/src/artifacts/inspector.test.ts b/frontend/workspace/src/artifacts/inspector.test.ts index 3810fd62..40d34fd4 100644 --- a/frontend/workspace/src/artifacts/inspector.test.ts +++ b/frontend/workspace/src/artifacts/inspector.test.ts @@ -291,6 +291,15 @@ describe("artifact inspector presentation", () => { expect(component).toContain('"min-height": CONTROL_HEIGHT') expect(component).toContain('"border-radius": GROUP_RADIUS') expect(component).not.toMatch(/"font-size": "(?:8|9|10|11|12)px"/) + expect(component).not.toMatch(/"font-weight":\s*(?:[1-9]\d{2}|"[1-9]\d{2}")/) + for (const strong of component.matchAll(/<strong\b[\s\S]*?<\/strong>/g)) { + expect(strong[0]).toContain('"font-weight": "var(--font-weight-') + } + expect(component).toMatch(/>\s*Close\s*</) + expect(component).toContain("<IconFlask size={16} /> Ask") + expect(component).toContain("<IconCopy size={16} /> Path") + expect(component).toContain("<IconDownload size={16} /> Download") + expect(component).toContain('title="Refresh file details"') }) test("renders producing runs and messages instead of only empty states", () => { diff --git a/frontend/workspace/src/artifacts/model.ts b/frontend/workspace/src/artifacts/model.ts index e5facfa2..bf66f43a 100644 --- a/frontend/workspace/src/artifacts/model.ts +++ b/frontend/workspace/src/artifacts/model.ts @@ -103,8 +103,6 @@ export function artifactActions(artifact: ArtifactInfo): ArtifactAction[] { description: "Execute cells, preserve outputs, and fix failures.", instruction: "Open this notebook, inspect it, and run it cell by cell. Repair failures without losing outputs.", }, - // Reviewing is a direct action (POST /session/:id/review), never a chat - // prompt — the session header's "Run review" launches the reviewer. { id: "export-notebook", label: "Prepare report", diff --git a/frontend/workspace/src/artifacts/resource.test.ts b/frontend/workspace/src/artifacts/resource.test.ts index 6f7f4212..9f843ad5 100644 --- a/frontend/workspace/src/artifacts/resource.test.ts +++ b/frontend/workspace/src/artifacts/resource.test.ts @@ -67,6 +67,8 @@ describe("stored artifacts resource", () => { expect(snapshot.active).toEqual([]) expect(snapshot.trash.map((item) => item.id)).toEqual(["art_2"]) + expect(snapshot.errors.active).toContain("Artifact store unavailable") + expect(snapshot.errors.trash).toBeUndefined() }) test("drops records the normalizer cannot vouch for instead of passing them through", async () => { diff --git a/frontend/workspace/src/artifacts/resource.ts b/frontend/workspace/src/artifacts/resource.ts index 12e89744..8b487cfb 100644 --- a/frontend/workspace/src/artifacts/resource.ts +++ b/frontend/workspace/src/artifacts/resource.ts @@ -11,6 +11,8 @@ export type ArtifactsRequest = (path: string, init?: RequestInit) => Promise<Res export interface ArtifactsSnapshot { active: StoredArtifact[] trash: StoredArtifact[] + /** Per-list failures stay visible without hiding the half that did load. */ + errors: Partial<Record<"active" | "trash", string>> } async function listArtifacts(request: ArtifactsRequest, state: "active" | "trash") { @@ -25,11 +27,20 @@ async function listArtifacts(request: ArtifactsRequest, state: "active" | "trash * only the active listing is broken, and the reverse. */ export function loadStoredArtifacts(request: ArtifactsRequest): Promise<ArtifactsSnapshot> { - return Promise.all((["active", "trash"] as const).map((state) => listArtifacts(request, state).catch(() => []))).then( - ([active, trash]) => ({ active, trash }), + return Promise.allSettled((["active", "trash"] as const).map((state) => listArtifacts(request, state))).then( + ([active, trash]) => ({ + active: active.status === "fulfilled" ? active.value : [], + trash: trash.status === "fulfilled" ? trash.value : [], + errors: { + ...(active.status === "rejected" ? { active: errorMessage(active.reason) } : {}), + ...(trash.status === "rejected" ? { trash: errorMessage(trash.reason) } : {}), + }, + }), ) } +const errorMessage = (value: unknown) => (value instanceof Error ? value.message : String(value || "Request failed")) + /** Undo a trash: POST /file/artifact-store/:id/restore (routes/file.ts:491). */ export async function restoreStoredArtifact(request: ArtifactsRequest, id: string) { const response = await request(`/file/artifact-store/${encodeURIComponent(id)}/restore`, { method: "POST" }) diff --git a/frontend/workspace/src/atlas/AtlasCanvas.accessibility.test.ts b/frontend/workspace/src/atlas/AtlasCanvas.accessibility.test.ts new file mode 100644 index 00000000..5c95302f --- /dev/null +++ b/frontend/workspace/src/atlas/AtlasCanvas.accessibility.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = () => readFileSync(fileURLToPath(new URL("./AtlasCanvas.tsx", import.meta.url)), "utf8") + +describe("Atlas canvas accessibility", () => { + test("keeps graph nodes named, focusable, and keyboard operable", () => { + const canvas = source() + + expect(canvas).toContain('aria-label="Atlas research graph. Use Tab to move between nodes and Enter to open one."') + expect(canvas).toContain("data-node-id={s.id}") + expect(canvas).toContain('role="button"') + expect(canvas).toContain("tabindex={0}") + expect(canvas).toContain("aria-pressed={sel()}") + expect(canvas).toContain("onfocus={(event) =>") + expect(canvas).toContain('event.key !== "Enter" && event.key !== " "') + expect(canvas).toContain("setSelectedID((current) => (current === s.id ? null : s.id))") + }) +}) diff --git a/frontend/workspace/src/atlas/AtlasCanvas.tsx b/frontend/workspace/src/atlas/AtlasCanvas.tsx index b5afa9b0..d31b3495 100644 --- a/frontend/workspace/src/atlas/AtlasCanvas.tsx +++ b/frontend/workspace/src/atlas/AtlasCanvas.tsx @@ -749,7 +749,7 @@ export function AtlasCanvas(): JSX.Element { style={{ "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", color: "var(--color-text)", overflow: "hidden", "text-overflow": "ellipsis", @@ -765,7 +765,7 @@ export function AtlasCanvas(): JSX.Element { "flex-shrink": 0, "font-family": FONT_MONO, "font-size": "10px", - "font-weight": 700, + "font-weight": "var(--font-weight-emphasis)", color: "var(--color-text-muted)", background: "var(--color-bg-subtle)", border: "1px solid var(--color-border)", @@ -785,21 +785,21 @@ export function AtlasCanvas(): JSX.Element { {/* Actions */} <div style={{ display: "flex", "align-items": "center", gap: "1px", "flex-shrink": 0 }}> <Show when={mode() === "orbit"}> - <CanvasAction title="reset pinned layout" disabled={saved().size === 0} onClick={resetLayout}> - <span style={{ "font-size": "10px", "letter-spacing": "0.04em" }}>reset</span> + <CanvasAction title="Reset Pinned Layout" disabled={saved().size === 0} onClick={resetLayout}> + <span style={{ "font-size": "10px", "letter-spacing": "0.04em" }}>Reset</span> </CanvasAction> </Show> - <CanvasAction title="fit to view" onClick={fit}> + <CanvasAction title="Fit to View" onClick={fit}> <FitGlyph /> </CanvasAction> <CanvasAction - title={selectedID() ? "stage a child under the selected node" : "stage a node under the graph root"} + title={selectedID() ? "Stage a Child Under the Selected Node" : "Stage a Node Under the Graph Root"} disabled={creating() || !graphId()} onClick={() => void createNode()} > <IconPlus size={12} strokeWidth={1.7} /> </CanvasAction> - <CanvasAction title="refresh" onClick={refresh}> + <CanvasAction title="Refresh" onClick={refresh}> <IconRefresh size={11} strokeWidth={1.6} /> </CanvasAction> </div> @@ -871,6 +871,8 @@ export function AtlasCanvas(): JSX.Element { ref={svgRef} width="100%" height="100%" + role="group" + aria-label="Atlas research graph. Use Tab to move between nodes and Enter to open one." style={{ display: "block", cursor: panStart ? "grabbing" : "grab", "touch-action": "none" }} onpointerdown={onPointerDown} onpointermove={onPointerMove} @@ -933,11 +935,26 @@ export function AtlasCanvas(): JSX.Element { <Show when={node()}> <g data-node-id={s.id} + role="button" + tabindex={0} + aria-pressed={sel()} + aria-label={`${node()!.title || node()!.slug_name || "Untitled node"}, ${node()!.kind || "untyped"}${node()!.outcome ? `, ${node()!.outcome}` : node()!.lifecycle === "staged" ? ", staged" : ""}`} transform={(frame(), `translate(${s.x},${s.y})`)} style={{ cursor: "pointer", opacity: dim() ? 0.26 : 1 }} onmouseenter={(e) => setHover({ id: s.id, x: e.clientX, y: e.clientY })} onmousemove={(e) => setHover({ id: s.id, x: e.clientX, y: e.clientY })} onmouseleave={() => setHover((h) => (h?.id === s.id ? null : h))} + onfocus={(event) => { + const rect = event.currentTarget.getBoundingClientRect() + setHover({ id: s.id, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) + }} + onblur={() => setHover((current) => (current?.id === s.id ? null : current))} + onkeydown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + event.stopPropagation() + setSelectedID((current) => (current === s.id ? null : s.id)) + }} > <Show when={mode() === "cards"} @@ -1090,7 +1107,8 @@ export function AtlasCanvas(): JSX.Element { <button type="button" onClick={() => setGraphMenu((v) => !v)} - title="switch graph" + title="Switch Graph" + aria-label="Switch Graph" style={{ all: "unset", cursor: "pointer", @@ -1110,7 +1128,7 @@ export function AtlasCanvas(): JSX.Element { style={{ "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", color: "var(--color-text)", overflow: "hidden", "text-overflow": "ellipsis", @@ -1149,8 +1167,8 @@ export function AtlasCanvas(): JSX.Element { "pointer-events": "none", }} > - <div>scroll to zoom · drag to move</div> - <div>click a node to open</div> + <div>Scroll to zoom · drag to move</div> + <div>Click a node to open · Tab for keyboard access</div> </div> </div> </Show> @@ -1191,7 +1209,7 @@ function CardNode(props: { node: AtlasNode; selected: boolean; hovered: boolean y={-CARD_H / 2 + 36} font-size="13" fill="var(--color-text)" - style={{ "font-family": FONT_SANS, "font-weight": 400, "pointer-events": "none" }} + style={{ "font-family": FONT_SANS, "font-weight": "var(--font-weight-regular)", "pointer-events": "none" }} > {truncate(props.node.title || props.node.slug_name || "untitled", 28)} </text> @@ -1242,7 +1260,7 @@ function OrbitTooltip(props: { node: AtlasNode; x: number; y: number; byId: Map< style={{ "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", color: "var(--color-text)", "line-height": 1.3, }} @@ -1335,7 +1353,7 @@ function NodeDetail(props: { node: AtlasNode; onClose: () => void }): JSX.Elemen style={{ "font-family": FONT_SANS, "font-size": "14px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", color: "var(--color-text)", flex: 1, overflow: "hidden", @@ -1560,7 +1578,7 @@ function EmptyHero(props: { onCreate: () => void }): JSX.Element { color: "var(--color-on-accent)", "font-family": FONT_MONO, "font-size": "11px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", display: "inline-flex", "align-items": "center", gap: "6px", @@ -1622,7 +1640,7 @@ function InitHero(props: { onInit: () => void; onChat: () => void; busy: boolean color: "var(--color-on-accent)", "font-family": FONT_MONO, "font-size": "11px", - "font-weight": 400, + "font-weight": "var(--font-weight-regular)", display: "inline-flex", "align-items": "center", gap: "6px", diff --git a/frontend/workspace/src/atlas/CommandCard.test.tsx b/frontend/workspace/src/atlas/CommandCard.test.tsx index 9aa21ab4..9acea48d 100644 --- a/frontend/workspace/src/atlas/CommandCard.test.tsx +++ b/frontend/workspace/src/atlas/CommandCard.test.tsx @@ -54,8 +54,9 @@ test("live shell commands share the compact compute ledger", () => { ) expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Preparing Titanic dataset") - expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("bash · python prepare.py") - expect(host.querySelectorAll(".kernel-card__metric")[0]?.textContent).toBe("12 MBrss") - expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("0.8cores") + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Shell · python prepare.py") + expect(host.querySelector(".kernel-card__uptime small")?.textContent).toBe("Runtime") + expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("12 MBMemory") + expect(host.querySelectorAll(".kernel-card__metric")[2]?.textContent).toBe("0.8CPU cores") expect(host.querySelector('button[aria-label="Stop Preparing Titanic dataset"]')).not.toBeNull() }) diff --git a/frontend/workspace/src/atlas/CommandCard.tsx b/frontend/workspace/src/atlas/CommandCard.tsx index f83d3913..b7df8d6d 100644 --- a/frontend/workspace/src/atlas/CommandCard.tsx +++ b/frontend/workspace/src/atlas/CommandCard.tsx @@ -1,4 +1,5 @@ import { createSignal, onCleanup, type JSX } from "solid-js" +import { IconTerminal } from "@/atlas/shared/Icon" import { kernelMemoryLabel, type CommandStatus } from "@/notebook/runtime" const memory = (value?: number) => { @@ -28,28 +29,35 @@ export function CommandCard(props: { command: CommandStatus; stopping: boolean; <article class="kernel-card command-card" data-command-id={props.command.id} data-state="running"> <div class="kernel-card__main"> <span class="kernel-card__language" aria-hidden="true"> - sh + <IconTerminal size={14} strokeWidth={1.4} /> </span> <div class="kernel-card__copy"> <strong title={props.command.description}>{props.command.description}</strong> <span title={props.command.command}> <i data-tone="active" aria-hidden="true" /> - bash · {props.command.command} + Shell · {props.command.command} </span> </div> </div> - <span class="kernel-card__uptime" aria-label={`Uptime ${uptime(props.command.started_at, now())}`}> - {uptime(props.command.started_at, now())} - </span> - <Metric label="rss" value={memory(props.command.resources?.memory_bytes)} /> - <Metric label="cores" value={cores(props.command.resources?.cpu_percent)} /> + <div class="kernel-card__metrics" aria-label="Command resource use"> + <span + class="kernel-card__uptime kernel-card__metric" + aria-label={`Runtime ${uptime(props.command.started_at, now())}`} + > + <strong>{uptime(props.command.started_at, now())}</strong> + <small>Runtime</small> + </span> + <Metric label="Memory" value={memory(props.command.resources?.memory_bytes)} /> + <Metric label="CPU cores" value={cores(props.command.resources?.cpu_percent)} /> + </div> <button type="button" class="kernel-card__stop" aria-label={`Stop ${props.command.description}`} title="Stop this live shell command and its child processes." disabled={props.stopping} + aria-busy={props.stopping} onClick={props.onStop} > {props.stopping ? "Stopping…" : "Stop"} diff --git a/frontend/workspace/src/atlas/CommandPalette.css b/frontend/workspace/src/atlas/CommandPalette.css new file mode 100644 index 00000000..bb60ef42 --- /dev/null +++ b/frontend/workspace/src/atlas/CommandPalette.css @@ -0,0 +1,467 @@ +@keyframes command-palette-overlay-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes command-palette-in { + from { + opacity: 0; + transform: translate(-50%, -6px) scale(0.99); + } + + to { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } +} + +.command-palette__overlay { + background: rgb(0 0 0 / 0.38); + backdrop-filter: none; + -webkit-backdrop-filter: none; + animation: command-palette-overlay-in 120ms ease-out; +} + +.command-palette { + --command-palette-top: clamp(16px, 12dvh, 96px); + + top: var(--command-palette-top); + left: 50%; + width: min(680px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + min-height: 0; + max-height: min(640px, calc(100dvh - var(--command-palette-top) - 12px)); + overflow: clip; + transform: translate(-50%, 0); + border: 1px solid var(--color-border); + border-radius: var(--atlas-radius-md); + background: var(--color-surface-solid); + box-shadow: var(--atlas-shadow-float); + font-family: var(--font-family-sans); + font-weight: var(--font-weight-regular); + animation: command-palette-in 140ms var(--agent-ease); + container: command-palette / inline-size; + isolation: isolate; +} + +.command-palette__sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.command-palette__header { + flex: 0 0 auto; + padding: 7px; + border-bottom: 1px solid var(--border-weak-base); + background: var(--color-surface-solid); +} + +.command-palette__search { + min-width: 0; + min-height: 48px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 9px; + border-radius: var(--atlas-radius-xs); + color: var(--color-text-muted); + background: transparent; + transition: + background-color 120ms ease, + box-shadow 120ms ease; +} + +.command-palette__search:focus-within { + background: var(--color-bg-subtle); + box-shadow: inset 0 0 0 1px var(--color-border); +} + +.command-palette__search-icon { + width: 22px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-faint); +} + +.command-palette__search:focus-within .command-palette__search-icon { + color: var(--color-text-muted); +} + +.command-palette__search input { + min-width: 0; + height: 46px; + padding: 0; + border: 0; + outline: 0; + color: var(--color-text); + background: transparent; + font: inherit; + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: 1.4; +} + +.command-palette__search input::placeholder { + color: var(--color-text-faint); + opacity: 1; + font: inherit; +} + +.command-palette__context { + min-width: 0; + max-width: min(260px, 42cqi); + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + color: var(--color-text-faint); + font-size: 11.5px; + font-weight: var(--font-weight-regular); + line-height: 1; + white-space: nowrap; +} + +.command-palette__scope, +.command-palette__search-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette__scope { + color: var(--color-text-muted); +} + +.command-palette__context-separator { + flex: 0 0 auto; + color: var(--color-text-faint); +} + +.command-palette__results-shell { + min-width: 0; + min-height: 0; + max-height: min(52dvh, 500px); + flex: 1 1 auto; + overflow-x: clip; + overflow-y: auto; + overscroll-behavior: contain; + scroll-padding-block: 6px; + padding: 5px 6px 7px; + background: var(--color-surface-solid); +} + +.command-palette__results, +.command-palette__group, +.command-palette__options { + min-width: 0; +} + +.command-palette__group { + display: block; + padding: 3px 0 5px; +} + +.command-palette__group + .command-palette__group { + margin-top: 2px; +} + +.command-palette__group-heading { + min-width: 0; + min-height: 26px; + display: flex; + align-items: center; + padding: 4px 9px 3px; +} + +.command-palette__group-title { + min-width: 0; + overflow: hidden; + color: var(--color-text-faint); + font-size: 11.5px; + font-weight: var(--font-weight-medium); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette__options { + display: grid; + gap: 1px; +} + +.command-palette__option { + appearance: none; + min-width: 0; + width: 100%; + min-height: 44px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 6px 9px; + border: 0; + border-radius: var(--atlas-radius-xs); + color: var(--color-text); + background: transparent; + font-family: var(--font-family-sans); + font-weight: var(--font-weight-regular); + text-align: left; + cursor: pointer; +} + +.command-palette__option:hover, +.command-palette__option[aria-selected="true"] { + background: var(--color-bg-subtle); +} + +.command-palette__option:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} + +.command-palette__option:active { + transform: scale(0.997); +} + +.command-palette__option-icon { + width: 22px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-faint); +} + +.command-palette__option[aria-selected="true"] .command-palette__option-icon { + color: var(--color-text-muted); +} + +.command-palette__option-copy { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(100px, 42%); + align-items: center; + gap: 16px; +} + +.command-palette__option-label, +.command-palette__option-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette__option-label { + color: var(--color-text); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1.4; +} + +.command-palette__option-hint { + color: var(--color-text-faint); + font-size: 11.5px; + font-weight: var(--font-weight-regular); + line-height: 1.35; + text-align: right; +} + +.command-palette__state { + min-height: 84px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 14px 10px; + color: var(--color-text-faint); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.45; + text-align: left; +} + +.command-palette__state-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.command-palette__state strong { + color: var(--color-text-muted); + font-size: 12.5px; + font-weight: var(--font-weight-medium); +} + +.command-palette__state button { + min-height: 32px; + flex: 0 0 auto; + margin-left: auto; + padding: 0 10px; + border: 1px solid var(--color-border); + border-radius: var(--atlas-radius-xs); + color: var(--color-text); + background: transparent; + font: inherit; + font-weight: var(--font-weight-regular); + cursor: pointer; +} + +.command-palette__state button:hover { + background: var(--color-bg-subtle); +} + +.command-palette__state button:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.command-palette__state--loading { + min-height: 42px; + padding-block: 8px; +} + +.command-palette__state--error { + justify-content: space-between; +} + +.command-palette__state-indicator { + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--color-text-muted); +} + +.command-palette__footer { + min-width: 0; + min-height: 36px; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 15px; + padding: 0 14px; + border-top: 1px solid var(--border-weak-base); + color: var(--color-text-faint); + background: var(--color-surface-solid); + font-size: 10.5px; + font-weight: var(--font-weight-regular); +} + +.command-palette__hint { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.command-palette__hint kbd { + color: var(--color-text-muted); + font-family: var(--font-family-sans); + font-size: 10.5px; + font-weight: var(--font-weight-regular); + line-height: 1; +} + +.command-palette__footer-spacer { + flex: 1; +} + +.command-palette__footer-source { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container command-palette (max-width: 560px) { + .command-palette__context { + max-width: 190px; + } + + .command-palette__option-copy { + grid-template-columns: minmax(0, 1fr); + gap: 1px; + } + + .command-palette__option-hint { + text-align: left; + } + + .command-palette__footer { + gap: 11px; + } +} + +@container command-palette (max-width: 420px) { + .command-palette__search { + grid-template-columns: 22px minmax(0, 1fr) auto; + padding-inline: 7px; + } + + .command-palette__scope, + .command-palette__context-separator, + .command-palette__footer-source, + .command-palette__hint > span { + display: none; + } + + .command-palette__context { + max-width: 86px; + } + + .command-palette__results-shell { + padding-inline: 4px; + } + + .command-palette__footer-spacer { + display: none; + } +} + +@media (max-width: 560px) { + .command-palette { + --command-palette-top: 8px; + + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); + max-height: calc(100dvh - 16px); + } +} + +@media (pointer: coarse) { + .command-palette__option { + min-height: 48px; + } + + .command-palette__state button { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .command-palette, + .command-palette__overlay { + animation: none; + } + + .command-palette__search { + transition: none; + } +} diff --git a/frontend/workspace/src/atlas/CommandPalette.test.ts b/frontend/workspace/src/atlas/CommandPalette.test.ts index dc29fa97..c26e032e 100644 --- a/frontend/workspace/src/atlas/CommandPalette.test.ts +++ b/frontend/workspace/src/atlas/CommandPalette.test.ts @@ -1,63 +1,186 @@ import { expect, test } from "bun:test" const source = await Bun.file(new URL("./CommandPalette.tsx", import.meta.url)).text() +const styles = await Bun.file(new URL("./CommandPalette.css", import.meta.url)).text() +const shell = await Bun.file(new URL("../styles/atlas.css", import.meta.url)).text() +const commands = await Bun.file(new URL("../context/command.tsx", import.meta.url)).text() +const home = await Bun.file(new URL("../pages/home.tsx", import.meta.url)).text() +const session = await Bun.file(new URL("../pages/session.tsx", import.meta.url)).text() -test("uses a centered project command palette", async () => { - const styles = await Bun.file(new URL("../styles/atlas.css", import.meta.url)).text() - expect(source).toContain('class="atlas-modal atlas-fade-in command-palette"') +test("uses the shared modal foundation for a stable, focus-contained palette", async () => { + expect(source).toContain('import { Dialog as Kobalte } from "@kobalte/core/dialog"') + expect(source).toContain('import "./CommandPalette.css"') + expect(source).toContain("<Kobalte.Content") + expect(source).toContain("<Kobalte.Overlay") + expect(source).toContain("onOpenAutoFocus") + expect(source).toContain("onCloseAutoFocus") + expect(source).toContain("if (restoreFocus?.isConnected) restoreFocus.focus()") + expect(source).toContain('class="atlas-modal command-palette"') + expect(source).not.toContain('class="atlas-modal atlas-fade-in command-palette"') expect(source).not.toContain("conversation-center") expect(styles).toContain(".command-palette") - expect(styles).toContain("transform: translate(-50%, -50%)") - expect(source).toContain('"min-height": "40px"') - expect(source).toContain('"font-size": "13px"') - expect(source).toContain('"font-size": "12px"') + expect(styles).toContain("width: min(680px, calc(100vw - 24px))") + expect(styles).toContain("top: var(--command-palette-top)") + expect(styles).toContain("max-height: min(640px, calc(100dvh - var(--command-palette-top) - 12px))") + expect(styles).toContain("transform: translate(-50%, 0)") + expect(styles).toContain("@keyframes command-palette-in") + expect(shell).not.toContain("command-palette-in") + expect(shell).not.toContain(".command-palette") expect(source).toContain('role="dialog"') expect(source).toContain('aria-modal="true"') + expect(source).toContain('aria-labelledby="command-palette-title"') + expect(source).toContain('aria-describedby="command-palette-description"') + expect(source).not.toContain('window.addEventListener("keydown"') +}) + +test("renders a quiet project-search header with responsive, overflow-safe styling", () => { + expect(source).toContain("Search this project") + expect(source).toContain("Search sessions, transcript messages, artifacts, and project actions.") + expect(source).toContain("Search sessions, messages, files, and actions…") + expect(source).toContain('class="command-palette__scope"') + expect(source).toContain('class="command-palette__search"') + expect(source).toContain('class="command-palette__sr-only"') + expect(source).not.toContain("style={{") + expect(styles).toContain("min-height: 0") + expect(styles).toContain("overflow-x: clip") + expect(styles).toContain("overflow-y: auto") + expect(styles).toContain("grid-template-columns: 22px minmax(0, 1fr) auto") + expect(styles).toContain("@container command-palette (max-width: 420px)") + expect(styles).toContain("width: calc(100vw - 16px)") +}) + +test("uses restrained semantic typography, radii, and usable hit targets", () => { + expect(styles).toContain("font-weight: var(--font-weight-regular)") + expect(styles).toContain("font-weight: var(--font-weight-medium)") + expect(styles).not.toMatch(/font-weight:\s*(?:[1-9]00|bold|normal)/) + expect(styles).toContain("border-radius: var(--atlas-radius-md)") + expect(styles).toContain("border-radius: var(--atlas-radius-xs)") + expect(styles).not.toMatch(/border-radius:\s*(?:7|8|16|999)px/) + expect(styles).toContain("border: 1px solid var(--color-border)") + expect(styles).toContain("border-bottom: 1px solid var(--border-weak-base)") + expect(styles).toContain("background: var(--color-surface-solid)") + expect(styles).not.toContain("color-mix(") + expect(styles).not.toMatch(/border(?:-(?:top|right|bottom|left))?:.*color-mix/) + expect(styles).toContain("min-height: 44px") + expect(styles).toContain("@media (pointer: coarse)") + expect(styles).toContain("min-height: 48px") +}) + +test("removes decorative hierarchy that made search feel like a dashboard", () => { + expect(source).not.toContain("command-palette__heading-copy") + expect(source).not.toContain("command-palette__group-note") + expect(source).not.toContain("command-palette__group-count") + expect(source).not.toContain("command-palette__option-enter") + expect(source).toContain('class="command-palette__footer" aria-hidden="true"') + expect(styles).not.toContain("scrollbar-gutter") + expect(styles).toContain("backdrop-filter: none") +}) + +test("renders registered commands instead of maintaining a shadow action list", () => { + expect(source).toContain("const command = useCommand()") + expect(source).toContain("return command.options.flatMap") + expect(source).toContain('command.trigger(option.id, "palette")') + expect(source).not.toContain('id: "open-files"') + expect(source).not.toContain('id: "compute-monitor"') + expect(source).not.toContain("DialogSettings") + expect(source).not.toContain("FolderPicker") + expect(source).not.toContain("lite: true") + expect(source).toContain("pendingRun = item.run") + expect(source).toContain("if (run) queueMicrotask(run)") + expect(source).toContain("command.keybinds(false)") + expect(source).toContain("command.keybinds(true)") +}) + +test("registers route-owned actions and removes the dead file.open palette path", () => { + expect(home).toContain('id: "project.create"') + expect(home).toContain('id: "project.import"') + expect(home).toContain('id: "server.switch"') + expect(session).toContain('id: "session.new"') + expect(session).toContain('id: "project.files"') + expect(session).toContain('id: "project.compute"') + expect(session).toContain('id: "settings.open"') + expect(session).toContain('id: "documentation.open"') + expect(commands).not.toContain('run("file.open", "palette")') + expect(commands).not.toContain("showPalette") }) test("wires /search through the project-scoped request helper with debounce and cancellation", () => { - expect(source).toContain('request("/search", { signal: controller.signal }, { q })') + expect(source).toContain('requestProjectSearch(() => request("/search", { signal: controller.signal }, { q }))') expect(source).toContain("createProjectRequest({") expect(source).toContain("const DEBOUNCE = 250") expect(source).toContain("setTimeout(") // debounced fetch expect(source).toContain("new AbortController()") expect(source).toContain("inflight?.abort()") // stale requests cancelled + expect(source).toMatch( + /if \(!scoped\)[\s\S]*setHits\(undefined\)[\s\S]*setHits\(undefined\)[\s\S]*setSearching\(true\)/, + ) expect(source).toContain("q.length >= 2") // no fetch below the server minimum expect(source).toContain("resolveProjectRoute(params.dir, sync.data.project)") + expect(source).toContain("requestProjectSearch") + expect(source).toContain("setSearchError(true)") + expect(source).toContain("setSearchRetry((value) => value + 1)") }) test("renders the three search groups and routes selection correctly", () => { - expect(source).toContain('category: "sessions"') - expect(source).toContain('category: "messages"') - expect(source).toContain('category: "artifacts"') + expect(source).toContain('category: "Sessions"') + expect(source).toContain('category: "Messages"') + expect(source).toContain('category: "Artifacts"') expect(source).toContain("projectHref(scope.project, scope.directory, s.id)") expect(source).toContain("projectHref(scope.project, scope.directory, m.sessionID)") expect(source).toContain("reveal(m.messageID)") expect(source).toContain("data-message-id") // transcript anchor for scroll-to-message expect(source).toContain("uiStore.openFile(scope.directory, a.path)") + expect(source).not.toContain("groupNote(") + expect(source).not.toContain("group.cmds.length") }) -test("keeps project search local and shows recent sessions before commands", () => { - const scoped = source.indexOf("if (scope) {") - const projects = source.indexOf("sync.data.project.forEach") - expect(scoped).toBeGreaterThan(0) - expect(projects).toBeGreaterThan(scoped) - expect(source.slice(scoped, projects)).toContain("return list") - expect(source).toContain('category: "recent sessions"') - expect(source).toContain('category: "commands"') +test("keeps project search local and shows recent sessions before registered commands", () => { + expect(source).toContain("if (active()) return []") + expect(source).toContain("sync.data.project.map") + expect(source).toContain('category: "Recent sessions"') + expect(source).toContain("const available = [...registered(), ...projects()]") + expect(source).toContain(": [...recent(), ...available]") }) test("keeps the flat selection model across search groups", () => { expect(source).toContain("return [...base, ...results()]") + expect(source).toContain("if (q && active()) return [...results(), ...base]") +}) + +test("updates keyboard highlight state without animating between rows", () => { + expect(source).toContain("if (event.target !== inputRef) return") + expect(source).toContain('event.key === "ArrowDown"') + expect(source).toContain('event.key === "ArrowUp"') + expect(source).not.toContain('event.key === "Home"') + expect(source).not.toContain('event.key === "End"') + expect(source).not.toContain('transition: "background') + expect(source).toContain('scrollIntoView({ block: "nearest" })') + expect(styles).not.toMatch(/\.command-palette__option\s*\{[^}]*transition:/s) +}) + +test("exposes a combobox/listbox relationship and active option", () => { + expect(source).toContain('role="combobox"') + expect(source).toContain('aria-controls="command-palette-results"') + expect(source).toContain('role="listbox"') + expect(source).toContain('role="option"') + expect(source).toContain('tabindex="-1"') + expect(source).toContain("aria-activedescendant") + expect(source).toContain("aria-selected={highlighted() === idx()}") }) test("labels the palette as local search without overpromising", () => { - expect(source).toContain("Search this project…") - expect(source).toContain("local search") + expect(source).toContain("Search sessions, messages, files, and actions…") + expect(source).toContain("Local project search") expect(source).not.toContain("semantic") }) test("shows a searching row in flight and no-matches only after completion", () => { - expect(source).toContain("searching…") - expect(source).toContain("filtered().length > 0 || searching()") + expect(source).toContain("Searching…") + expect(source).toContain("No matches") + expect(source).toContain("!searching() && !searchError() && !short() && filtered().length === 0") + expect(source).toContain("Type one more character") + expect(source).toContain("Search unavailable") + expect(source).toContain("Project content could not be searched") + expect(source).toContain("Retry") + expect(source).toContain('aria-live="polite"') }) diff --git a/frontend/workspace/src/atlas/CommandPalette.tsx b/frontend/workspace/src/atlas/CommandPalette.tsx index 6b5cf85b..1de9e3b2 100644 --- a/frontend/workspace/src/atlas/CommandPalette.tsx +++ b/frontend/workspace/src/atlas/CommandPalette.tsx @@ -1,29 +1,17 @@ -import { createSignal, createMemo, createEffect, type JSX, Show, For, onMount, onCleanup } from "solid-js" -import { Portal } from "solid-js/web" +import { createSignal, createMemo, createEffect, type JSX, Show, For, onCleanup } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" -import { useDialog } from "@synsci/ui/context/dialog" -import { FONT_MONO, FONT_SANS } from "@/styles/tokens" +import { Dialog as Kobalte } from "@kobalte/core/dialog" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { usePlatform } from "@/context/platform" -import { DialogSettings } from "@/components/dialog-settings" -import { FolderPicker } from "@/atlas/FolderPicker" +import { useCommand } from "@/context/command" import { uiStore } from "@/atlas/store/ui" -import { - IconBookOpen, - IconCpu, - IconFile, - IconFolder, - IconMessageSquare, - IconSearch, - IconPlus, - IconHome, - IconSettings, -} from "@/atlas/shared/Icon" +import { IconBolt, IconFile, IconFolder, IconMessageSquare, IconSearch } from "@/atlas/shared/Icon" import { projectHref, resolveProjectRoute } from "@/utils/project-route" -import { projectHint, projectName } from "@/pages/home-projects" +import { projectName } from "@/pages/home-projects" import { createProjectRequest } from "@/utils/openscience-fetch" -import { URLS } from "@/config/urls" +import { requestProjectSearch, type ProjectSearchHits } from "@/atlas/project-search" +import "./CommandPalette.css" interface CommandPaletteProps { open: boolean @@ -37,16 +25,12 @@ interface Cmd { icon?: (p: { size?: number; strokeWidth?: number }) => JSX.Element category: string run: () => void + highlight?: () => (() => void) | void } // Shape of GET /search — plain-text, case-insensitive substring matches // scoped to the active project (capped at 20 per group server-side). -interface Hits { - sessions: Array<{ id: string; title: string }> - messages: Array<{ sessionID: string; messageID: string; role: string; snippet: string }> - artifacts: Array<{ path: string; name: string; kind: string }> -} - +type Hits = ProjectSearchHits const EMPTY: Hits = { sessions: [], messages: [], artifacts: [] } const DEBOUNCE = 250 const REVEAL_TIMEOUT = 2000 @@ -56,6 +40,11 @@ function routeName(project: { worktree: string }) { return parts[parts.length - 1] ?? "Current project" } +function sentenceCase(value: string | undefined, fallback = "Commands") { + const text = value?.trim() || fallback + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}` +} + // The transcript renders data-message-id anchors; after navigating to the // session the target may not be mounted yet, so retry for up to ~2s. function reveal(messageID: string) { @@ -74,15 +63,20 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { const [highlighted, setHighlighted] = createSignal(0) const [hits, setHits] = createSignal<Hits>() const [searching, setSearching] = createSignal(false) + const [searchError, setSearchError] = createSignal(false) + const [searchRetry, setSearchRetry] = createSignal(0) const navigate = useNavigate() const params = useParams() - const dialog = useDialog() + const command = useCommand() const sync = useGlobalSync() const global = useGlobalSDK() const platform = usePlatform() let inputRef: HTMLInputElement | undefined let timer: ReturnType<typeof setTimeout> | undefined let inflight: AbortController | undefined + let clearHighlight: (() => void) | undefined + let restoreFocus: HTMLElement | undefined + let pendingRun: (() => void) | undefined // The palette mounts on both the home page (no project) and project pages, // so the active project comes from the route rather than the SDK context. @@ -95,6 +89,7 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { }) createEffect(() => { + searchRetry() const q = query().trim() const scoped = props.open && q.length >= 2 && active() !== undefined if (timer) clearTimeout(timer) @@ -102,14 +97,19 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { if (!scoped) { setHits(undefined) setSearching(false) + setSearchError(false) return } + // Never present results from the previous query beneath a new search. + // The aborted request cannot update state, while clearing here also covers + // the debounce window before the replacement request starts. + setHits(undefined) setSearching(true) + setSearchError(false) timer = setTimeout(() => { const controller = new AbortController() inflight = controller - request("/search", { signal: controller.signal }, { q }) - .then((res) => (res.ok ? (res.json() as Promise<Hits>) : EMPTY)) + requestProjectSearch(() => request("/search", { signal: controller.signal }, { q })) .then((data) => { if (controller.signal.aborted) return setHits(data) @@ -119,6 +119,7 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { if (controller.signal.aborted) return setHits(EMPTY) setSearching(false) + setSearchError(true) }) }, DEBOUNCE) }) @@ -129,121 +130,38 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { }) const goTo = (project: (typeof sync.data.project)[number]) => navigate(projectHref(project)) - const openDirectory = (directory: string) => { - void sync.project - .resolve(directory) - .then(goTo) - .catch(() => undefined) - } - - const showInAppPicker = () => { - dialog.show( - () => ( - <FolderPicker - onSelect={(result) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - openDirectory(directory) - }} - /> - ), - { onClose: () => {}, lite: true }, - ) - } - - const openFolderPicker = async () => { - props.onClose() - // Always use the in-app FolderPicker for visual consistency with the - // rest of the UI — see the same reasoning in pages/home.tsx. - showInAppPicker() - } - - const cmds = createMemo<Cmd[]>(() => { - const list: Cmd[] = [] - const scope = active() - - if (scope) { - list.push({ - id: "new-session", - label: "New session", - hint: "⌘N", - icon: IconPlus, - category: "commands", - run: () => navigate(projectHref(scope.project, scope.directory, "new")), - }) - list.push({ - id: "open-files", - label: "Open project files", - hint: "Current project", - icon: IconFile, - category: "commands", - run: () => uiStore.openContext("files"), - }) - list.push({ - id: "compute-monitor", - label: "Compute monitor", - hint: "Current project", - icon: IconCpu, - category: "commands", - run: () => uiStore.openContext("kernels"), - }) - list.push({ - id: "documentation", - label: "Open documentation", - hint: "syntheticsciences.ai", - icon: IconBookOpen, - category: "commands", - run: () => platform.openLink(URLS.site), - }) - return list - } - - list.push({ - id: "new-project", - label: "Open folder…", - hint: "Click-to-navigate folder picker", - icon: IconPlus, - category: "actions", - run: openFolderPicker, - }) - list.push({ - id: "open-settings", - label: "Settings", - hint: "Models · keys · MCP · appearance", - icon: IconSettings, - category: "actions", - run: () => { - props.onClose() - dialog.show(() => <DialogSettings />) - }, - }) - list.push({ - id: "back-home", - label: "Back to projects", - hint: "Return to the project grid", - icon: IconHome, - category: "actions", - run: () => { - props.onClose() - navigate("/") - }, - }) - - sync.data.project.forEach((p) => { - list.push({ - id: `proj-${p.id}`, - label: projectName(p), - hint: projectHint(p), - icon: IconFolder, - category: "projects", - run: () => { - props.onClose() - goTo(p) + const registered = createMemo<Cmd[]>(() => { + const seen = new Set<string>() + return command.options.flatMap((option) => { + const id = option.id.replace(/^suggested\./, "") + if (option.disabled || seen.has(id)) return [] + seen.add(id) + const keybind = command.keybind(option.id) + const description = option.description ? sentenceCase(option.description) : undefined + const category = sentenceCase(option.category) + return [ + { + id: `command-${option.id}`, + label: sentenceCase(option.title), + hint: [category, description, keybind].filter(Boolean).join(" · ") || undefined, + category: "Actions", + run: () => command.trigger(option.id, "palette"), + highlight: option.onHighlight, }, - }) + ] }) + }) - return list + const projects = createMemo<Cmd[]>(() => { + if (active()) return [] + return sync.data.project.map((project) => ({ + id: `project-${project.id}`, + label: projectName(project), + hint: "Project workspace", + icon: IconFolder, + category: "Projects", + run: () => goTo(project), + })) }) const recent = createMemo<Cmd[]>(() => { @@ -257,9 +175,9 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { .map((session) => ({ id: `recent-${session.id}`, label: session.title || "New session", - hint: "Session", + hint: "Recent project session", icon: IconMessageSquare, - category: "recent sessions", + category: "Recent sessions", run: () => navigate(projectHref(scope.project, scope.directory, session.id)), })) }) @@ -275,10 +193,10 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { data.sessions.forEach((s) => { list.push({ id: `session-${s.id}`, - label: s.title, - hint: "open session", + label: s.title || "Untitled session", + hint: "Project session", icon: IconMessageSquare, - category: "sessions", + category: "Sessions", run: () => navigate(projectHref(scope.project, scope.directory, s.id)), }) }) @@ -286,9 +204,9 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { list.push({ id: `message-${m.messageID}`, label: m.snippet, - hint: `${m.role} · ${titles.get(m.sessionID) ?? m.sessionID}`, + hint: `${sentenceCase(m.role)} message · ${titles.get(m.sessionID) ?? m.sessionID}`, icon: IconSearch, - category: "messages", + category: "Messages", run: () => { navigate(projectHref(scope.project, scope.directory, m.sessionID)) reveal(m.messageID) @@ -299,9 +217,9 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { list.push({ id: `artifact-${a.path}`, label: a.name, - hint: a.kind, + hint: `${sentenceCase(a.kind)} · ${a.path}`, icon: IconFile, - category: "artifacts", + category: "Artifacts", run: () => uiStore.openFile(scope.directory, a.path), }) }) @@ -310,9 +228,11 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { const filtered = createMemo(() => { const q = query().toLowerCase().trim() + const available = [...registered(), ...projects()] const base = q - ? cmds().filter((c) => c.label.toLowerCase().includes(q) || c.hint?.toLowerCase().includes(q)) - : [...recent(), ...cmds()] + ? available.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q)) + : [...recent(), ...available] + if (q && active()) return [...results(), ...base] return [...base, ...results()] }) @@ -326,255 +246,260 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element { return Array.from(map.entries()).map(([category, cmds]) => ({ category, cmds })) }) - onMount(() => { - const onKey = (e: KeyboardEvent) => { - if (!props.open) return - if (e.key === "Escape") { - e.preventDefault() - props.onClose() - return - } - if (e.key === "ArrowDown") { - e.preventDefault() - setHighlighted((h) => Math.min(filtered().length - 1, h + 1)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setHighlighted((h) => Math.max(0, h - 1)) - } else if (e.key === "Enter") { - e.preventDefault() - const cmd = filtered()[highlighted()] - if (cmd) { - cmd.run() - props.onClose() - setQuery("") - setHighlighted(0) - } - } - } - window.addEventListener("keydown", onKey) - onCleanup(() => window.removeEventListener("keydown", onKey)) + const scope = createMemo(() => { + const project = active() + if (!project) return "All projects" + return routeName(project.project) + }) + + const status = createMemo(() => { + if (searching()) return "Searching…" + if (searchError()) return "Search unavailable" + const count = filtered().length + return `${count} ${count === 1 ? "result" : "results"}` }) + const showStatus = createMemo(() => searching() || searchError() || query().trim().length > 0) + + const short = createMemo(() => active() !== undefined && query().trim().length === 1) + + createEffect(() => { + const last = filtered().length - 1 + setHighlighted((current) => (last < 0 ? 0 : Math.min(current, last))) + }) + + createEffect(() => { + clearHighlight?.() + clearHighlight = undefined + if (!props.open) return + clearHighlight = filtered()[highlighted()]?.highlight?.() ?? undefined + }) + + createEffect(() => { + if (!props.open || filtered().length === 0) return + const id = `command-palette-option-${highlighted()}` + queueMicrotask(() => document.getElementById(id)?.scrollIntoView({ block: "nearest" })) + }) + + createEffect(() => { + if (!props.open) return + command.keybinds(false) + onCleanup(() => command.keybinds(true)) + }) + + onCleanup(() => clearHighlight?.()) + + const close = () => { + setQuery("") + setHighlighted(0) + props.onClose() + } + + const execute = (item: Cmd) => { + pendingRun = item.run + close() + } + + const onKeyDown = (event: KeyboardEvent) => { + // The retry action participates in the dialog focus trap. Let controls + // other than the combobox handle their own keys instead of executing the + // currently highlighted search result. + if (event.target !== inputRef) return + const last = filtered().length - 1 + if (event.key === "ArrowDown" && last >= 0) { + event.preventDefault() + setHighlighted((current) => (current >= last ? 0 : current + 1)) + return + } + if (event.key === "ArrowUp" && last >= 0) { + event.preventDefault() + setHighlighted((current) => (current <= 0 ? last : current - 1)) + return + } + if (event.key !== "Enter" || event.isComposing) return + const item = filtered()[highlighted()] + if (!item) return + event.preventDefault() + execute(item) + } + return ( - <Show when={props.open}> - <Portal> - <div class="atlas-overlay" onClick={props.onClose} /> - <div - class="atlas-modal atlas-fade-in command-palette" + <Kobalte + modal + open={props.open} + onOpenChange={(open) => { + if (!open) close() + }} + > + <Kobalte.Portal> + <Kobalte.Overlay class="atlas-overlay command-palette__overlay" /> + <Kobalte.Content + class="atlas-modal command-palette" role="dialog" aria-modal="true" - aria-label="command palette" - onClick={(e) => e.stopPropagation()} - ref={(el) => { - requestAnimationFrame(() => inputRef?.focus()) + aria-labelledby="command-palette-title" + aria-describedby="command-palette-description" + onKeyDown={onKeyDown} + onOpenAutoFocus={(event) => { + event.preventDefault() + const active = document.activeElement + restoreFocus = active instanceof HTMLElement && active !== document.body ? active : undefined + inputRef?.focus() + }} + onCloseAutoFocus={(event) => { + event.preventDefault() + if (restoreFocus?.isConnected) restoreFocus.focus() + const run = pendingRun + pendingRun = undefined + if (run) queueMicrotask(run) }} > - <div - style={{ - display: "flex", - "align-items": "center", - gap: "10px", - padding: "13px 16px", - "border-bottom": "1px solid var(--color-border)", - }} - > - <span style={{ display: "inline-flex", color: "var(--color-text-faint)" }}> - <IconSearch size={13} strokeWidth={1.5} /> - </span> - <input - ref={inputRef} - aria-label={active() ? "Search this project" : "Search projects and actions"} - value={query()} - onInput={(e) => { - setQuery(e.currentTarget.value) - setHighlighted(0) - }} - placeholder={active() ? "Search this project…" : "Search projects and actions…"} - autofocus - style={{ - all: "unset", - flex: 1, - "font-family": FONT_MONO, - "font-size": "13px", - color: "var(--color-text)", - // `all: unset` leaves the box flush with the glyphs, so the - // caret starts on the edge and the focus ring lands on the text. - padding: "3px 10px", - }} - /> - <span - style={{ - "font-family": FONT_MONO, - "font-size": "11px", - color: "var(--color-text-faint)", - "text-transform": "uppercase", - "letter-spacing": "0.08em", - }} - > - {active() ? routeName(active()!.project) : `${filtered().length} matches`} - </span> - </div> + <header class="command-palette__header"> + <Kobalte.Title id="command-palette-title" class="command-palette__sr-only"> + Command palette + </Kobalte.Title> + <Kobalte.Description id="command-palette-description" class="command-palette__sr-only"> + {active() + ? "Search sessions, transcript messages, artifacts, and project actions." + : "Open a project or run an available action."} + </Kobalte.Description> + + <div class="command-palette__search" data-searching={searching() ? "true" : undefined}> + <span class="command-palette__search-icon" aria-hidden="true"> + <IconSearch size={16} strokeWidth={1.5} /> + </span> + <input + ref={inputRef} + aria-label={active() ? "Search this project" : "Search projects and actions"} + role="combobox" + aria-autocomplete="list" + aria-controls="command-palette-results" + aria-expanded="true" + aria-activedescendant={filtered().length > 0 ? `command-palette-option-${highlighted()}` : undefined} + value={query()} + onInput={(event) => { + setQuery(event.currentTarget.value) + setHighlighted(0) + }} + placeholder={ + active() ? "Search sessions, messages, files, and actions…" : "Search projects and actions…" + } + autofocus + /> + <span class="command-palette__context" aria-hidden="true"> + <span class="command-palette__scope" title={scope()}> + {scope()} + </span> + <Show when={showStatus()}> + <span class="command-palette__context-separator">·</span> + <span class="command-palette__search-status">{status()}</span> + </Show> + </span> + </div> + </header> - <div class="atlas-scroll" style={{ "overflow-y": "auto", "max-height": "52vh", padding: "6px 0" }}> - <Show - when={filtered().length > 0 || searching()} - fallback={ - <div - style={{ - padding: "32px", - "text-align": "center", - "font-family": FONT_MONO, - "font-size": "11px", - color: "var(--color-text-faint)", - }} - > - no matches - </div> - } + <div class="atlas-scroll command-palette__results-shell"> + <div + id="command-palette-results" + class="command-palette__results" + role="listbox" + aria-label="Commands and search results" + aria-busy={searching()} > <For each={grouped()}> {(group) => ( - <div> - <div - style={{ - padding: "7px 16px 5px", - "font-family": FONT_MONO, - "font-size": "11px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", - color: "var(--color-text-faint)", - }} - > - {group.category} + <section class="command-palette__group" role="group" aria-label={group.category}> + <div class="command-palette__group-heading"> + <span class="command-palette__group-title">{group.category}</span> </div> - <For each={group.cmds}> - {(cmd) => { - const idx = () => filtered().indexOf(cmd) - return ( - <button - onClick={() => { - cmd.run() - props.onClose() - setQuery("") - setHighlighted(0) - }} - onMouseEnter={() => setHighlighted(idx())} - style={{ - all: "unset", - cursor: "pointer", - display: "flex", - "align-items": "center", - gap: "10px", - width: "100%", - "box-sizing": "border-box", - "min-height": "40px", - padding: "9px 16px", - background: highlighted() === idx() ? "var(--color-accent-subtle)" : "transparent", - transition: "background 120ms ease", - }} - > - <Show when={cmd.icon}> - <span - style={{ - display: "inline-flex", - color: "var(--color-text-faint)", - }} - > - {cmd.icon!({ size: 12, strokeWidth: 1.7 })} - </span> - </Show> - <span - style={{ - "font-family": FONT_MONO, - "font-size": "13px", - color: "var(--color-text)", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", + <div class="command-palette__options"> + <For each={group.cmds}> + {(cmd) => { + const idx = () => filtered().indexOf(cmd) + const glyph = cmd.icon ?? IconBolt + return ( + <button + class="command-palette__option" + type="button" + tabindex="-1" + id={`command-palette-option-${idx()}`} + role="option" + aria-selected={highlighted() === idx()} + onClick={() => { + execute(cmd) }} + onMouseEnter={() => setHighlighted(idx())} > - {cmd.label} - </span> - <Show when={cmd.hint}> - <span style={{ flex: 1 }} /> - <span - style={{ - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text-faint)", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - "max-width": "260px", - }} - > - {cmd.hint} + <span class="command-palette__option-icon" aria-hidden="true"> + {glyph({ size: 15, strokeWidth: 1.5 })} </span> - </Show> - </button> - ) - }} - </For> - </div> + <span class="command-palette__option-copy"> + <span class="command-palette__option-label">{cmd.label}</span> + <Show when={cmd.hint}> + <span class="command-palette__option-hint">{cmd.hint}</span> + </Show> + </span> + </button> + ) + }} + </For> + </div> + </section> )} </For> - <Show when={searching()}> - <div - style={{ - padding: "10px 16px", - "font-family": FONT_MONO, - "font-size": "11px", - "letter-spacing": "0.08em", - color: "var(--color-text-faint)", - }} - > - searching… - </div> - </Show> + </div> + <Show when={searching()}> + <div class="command-palette__state command-palette__state--loading" role="status" aria-live="polite"> + <span class="command-palette__state-indicator" aria-hidden="true" /> + <span>Searching…</span> + </div> + </Show> + <Show when={short() && filtered().length === 0}> + <div class="command-palette__state" role="status" aria-live="polite"> + <span class="command-palette__state-copy"> + <strong>Type one more character</strong> + <span>Project search starts after two characters.</span> + </span> + </div> + </Show> + <Show when={searchError() && !searching()}> + <div class="command-palette__state command-palette__state--error" role="alert"> + <span class="command-palette__state-copy"> + <strong>Search unavailable</strong> + <span>Project content could not be searched. Local actions are still available.</span> + </span> + <button type="button" onClick={() => setSearchRetry((value) => value + 1)}> + Retry + </button> + </div> + </Show> + <Show when={!searching() && !searchError() && !short() && filtered().length === 0}> + <div class="command-palette__state" role="status" aria-live="polite"> + <span class="command-palette__state-copy"> + <strong>No matches</strong> + <span>Try a session, message, file, or action.</span> + </span> + </div> </Show> </div> - <div - style={{ - display: "flex", - "align-items": "center", - gap: "12px", - padding: "8px 16px", - "border-top": "1px solid var(--color-border)", - background: "var(--color-bg-subtle)", - "font-family": FONT_MONO, - "font-size": "11px", - color: "var(--color-text-faint)", - }} - > - <Hint k="↑↓" l="navigate" /> - <Hint k="↵" l="select" /> - <Hint k="esc" l="close" /> - <span style={{ flex: 1 }} /> - <span style={{ "letter-spacing": "0.04em" }}>local search</span> - <span style={{ "letter-spacing": "0.04em" }}>⌘K</span> - </div> - </div> - </Portal> - </Show> + <footer class="command-palette__footer" aria-hidden="true"> + <span class="command-palette__footer-source">{active() ? "Local project search" : "OpenScience"}</span> + <span class="command-palette__footer-spacer" /> + <Hint k="↑↓" l="Navigate" /> + <Hint k="↵" l="Open" /> + <Hint k="Esc" l="Close" /> + </footer> + </Kobalte.Content> + </Kobalte.Portal> + </Kobalte> ) } function Hint(props: { k: string; l: string }): JSX.Element { return ( - <span style={{ display: "inline-flex", "align-items": "center", gap: "4px" }}> - <kbd - style={{ - "font-family": FONT_MONO, - "font-size": "10px", - padding: "0 4px", - border: "1px solid var(--color-border)", - "border-radius": "4px", - color: "var(--color-text-muted)", - }} - > - {props.k} - </kbd> + <span class="command-palette__hint"> + <kbd>{props.k}</kbd> <span>{props.l}</span> </span> ) diff --git a/frontend/workspace/src/atlas/ComputeJobs.test.ts b/frontend/workspace/src/atlas/ComputeJobs.test.ts index 752ba63d..e566650a 100644 --- a/frontend/workspace/src/atlas/ComputeJobs.test.ts +++ b/frontend/workspace/src/atlas/ComputeJobs.test.ts @@ -156,6 +156,7 @@ describe("compute jobs surface", () => { expect(apiSource).toContain('call<Job>("", { method: "POST"') expect(apiSource).toContain("call<Job>(`/${id}/retry`") expect(apiSource).toContain("call<Job>(`/${id}/cancel`") + expect(apiSource).toContain("call<Job>(`/${id}/release`") expect(apiSource).toContain("`/${id}/log`") expect(apiSource).toContain("`/${id}/events`") expect(source).toContain("job().artifacts") @@ -185,19 +186,23 @@ describe("compute jobs surface", () => { expect(source).toContain("terminal.has(status)") }) - test("uses backend authority and an exact approval plan for Modal dispatch", () => { - expect(source).toContain('target() === "modal" ? "remote_job" : "local_job"') - expect(source).toContain('target: { kind: "modal" }') - expect(source).toContain('approval: target() === "modal" ? plan()?.digest') + test("uses backend authority and exact approval plans for Modal and SSH dispatch", () => { + expect(source).toContain('target() === "local" ? "local_job" : "remote_job"') + expect(source).toContain('{ kind: "ssh", host_id: selectedHost()!.id }') + expect(source).toContain(': { kind: "modal" }') + expect(source).toContain('approval: target() !== "local" ? plan()?.digest') expect(source).toContain(".plan({") expect(source).toContain('data-testid="modal-plan"') + expect(source).toContain('data-testid="ssh-plan"') + expect(source).toContain("value={`ssh:${host.id}`} disabled={!host.fingerprint}") expect(source).toContain("!authority.allowed()") expect(source).toContain('title="Cancel job"') expect(source).toContain("api.cancel(job.id)") expect(source).toContain("api.retry(job.id)") + expect(source).toContain("api.release(job.id)") expect(source).toContain("Retry delivery") - expect(source).toContain('job().target.kind !== "ssh"') - expect(source).toContain("Remote dispatch is unavailable") + expect(source).toContain("Retry release") + expect(source).not.toContain("Remote dispatch is unavailable") expect(source).toContain("none (CPU only), T4, L4, A10G, A100, H100") }) diff --git a/frontend/workspace/src/atlas/ComputeJobs.tsx b/frontend/workspace/src/atlas/ComputeJobs.tsx index 12884c6d..dfde3bf6 100644 --- a/frontend/workspace/src/atlas/ComputeJobs.tsx +++ b/frontend/workspace/src/atlas/ComputeJobs.tsx @@ -10,8 +10,10 @@ import { createComputeJobsAPI, type Artifact, type Job, + type ModalPlan, type Plan, type Resources, + type SshPlan, type Status, serial, stableJobs, @@ -56,6 +58,7 @@ export function ComputeJobs( cache.value = value return value }) + const [compute] = createResource(() => api.settings()) const seeded = { value: false } const [state, setState] = createStore({ selected: undefined as string | undefined, @@ -65,7 +68,7 @@ export function ComputeJobs( name: "", command: "", cwd: "", - target: "local" as "local" | "modal", + target: "local" as "local" | "modal" | `ssh:${string}`, image: "", gpu: "T4", uploads: "", @@ -105,7 +108,7 @@ export function ComputeJobs( const cwd = () => state.cwd const setCwd: Setter<string> = (value) => setState("cwd", value) const target = () => state.target - const setTarget: Setter<"local" | "modal"> = (value) => setState("target", value) + const setTarget: Setter<"local" | "modal" | `ssh:${string}`> = (value) => setState("target", value) const image = () => state.image const setImage: Setter<string> = (value) => setState("image", value) const gpu = () => state.gpu @@ -113,6 +116,8 @@ export function ComputeJobs( const uploads = () => state.uploads const setUploads: Setter<string> = (value) => setState("uploads", value) const plan = () => state.plan + const modalPlan = createMemo(() => (plan()?.provider === "modal" ? (plan() as ModalPlan) : undefined)) + const sshPlan = createMemo(() => (plan()?.provider === "ssh" ? (plan() as SshPlan) : undefined)) const setPlan = (value: Plan | undefined) => { setState("plan", value) return value @@ -147,7 +152,12 @@ export function ComputeJobs( const setOutputBusy: Setter<boolean> = (value) => setState("outputBusy", value) const eventsBusy = () => state.eventsBusy const setEventsBusy: Setter<boolean> = (value) => setState("eventsBusy", value) - const authority = useExecutionAuthority(() => (target() === "modal" ? "remote_job" : "local_job")) + const authority = useExecutionAuthority(() => (target() === "local" ? "local_job" : "remote_job")) + const selectedHost = createMemo(() => { + const value = target() + if (!value.startsWith("ssh:")) return undefined + return compute()?.ssh_hosts.find((host) => host.id === value.slice(4)) + }) const current = createMemo(() => jobs()?.find((job) => job.id === selected())) const active = createMemo(() => jobs()?.filter((job) => !terminal.has(job.status)).length ?? 0) @@ -290,7 +300,7 @@ export function ComputeJobs( return { command: command().trim(), cwd: cwd().trim() || "Session workspace", - target: target() === "modal" ? "Modal" : "This computer", + target: target() === "modal" ? "Modal" : (selectedHost()?.label ?? "This computer"), resources: resources(), modules: listValue(modules()), container: container().trim() || undefined, @@ -332,8 +342,10 @@ export function ComputeJobs( name: name().trim(), command: command().trim(), cwd: cwd().trim() || undefined, - target: { kind: "modal" }, + target: selectedHost() ? { kind: "ssh", host_id: selectedHost()!.id } : { kind: "modal" }, resources: resources(), + modules: listValue(modules()), + container: container().trim() || undefined, artifacts: listValue(artifacts()), checkpoint: checkpoint().trim() || undefined, uploads: listValue(uploads()), @@ -341,7 +353,7 @@ export function ComputeJobs( gpu: gpu().trim(), }) .catch((error) => { - toast.error("Modal plan unavailable", error instanceof Error ? error.message : String(error)) + toast.error("Remote plan unavailable", error instanceof Error ? error.message : String(error)) return undefined }) setBusy(false) @@ -369,16 +381,20 @@ export function ComputeJobs( name: name().trim(), command: command().trim(), cwd: cwd().trim() || undefined, - target: target() === "modal" ? { kind: "modal" } : { kind: "local" }, + target: selectedHost() + ? { kind: "ssh", host_id: selectedHost()!.id } + : target() === "modal" + ? { kind: "modal" } + : { kind: "local" }, resources: resources(), modules: listValue(modules()), container: container().trim() || undefined, artifacts: listValue(artifacts()), checkpoint: checkpoint().trim() || undefined, - uploads: target() === "modal" ? listValue(uploads()) : undefined, + uploads: target() !== "local" ? listValue(uploads()) : undefined, image: target() === "modal" ? image().trim() || undefined : undefined, gpu: target() === "modal" ? gpu().trim() : undefined, - approval: target() === "modal" ? plan()?.digest : undefined, + approval: target() !== "local" ? plan()?.digest : undefined, }) .catch((error) => { toast.error("job did not start", error instanceof Error ? error.message : String(error)) @@ -429,21 +445,30 @@ export function ComputeJobs( void refresh() } + const release = async (job: Job) => { + setBusy(true) + const next = await api.release(job.id).catch((error) => { + toast.error("remote resources were not released", error instanceof Error ? error.message : String(error)) + return undefined + }) + setBusy(false) + if (!next) return + jobsApi.mutate((list) => { + const value = list?.map((item) => (item.id === next.id ? next : item)) + cache.value = value + return value + }) + void streams(job.id) + } + const rerun = (job: Job) => { - if (job.target.kind === "ssh") { - toast.error( - "remote rerun unavailable", - "SSH dispatch stays disabled until staging, reattachment, cancellation, logs, and outputs pass real-host validation.", - ) - return - } setName(job.name) setCommand(job.command) setCwd("") - setTarget(job.target.kind === "modal" ? "modal" : "local") + setTarget(job.target.kind === "ssh" ? `ssh:${job.target.host_id}` : job.target.kind === "modal" ? "modal" : "local") setImage(job.modal?.image ?? "") setGpu(job.modal?.gpu ?? "T4") - setUploads(job.modal?.uploads.map((file) => file.path).join(", ") ?? "") + setUploads((job.modal?.uploads ?? job.ssh?.uploads)?.map((file) => file.path).join(", ") ?? "") setPlan(undefined) setStamp("") setCpus(job.resources?.cpus?.toString() ?? "") @@ -561,13 +586,21 @@ export function ComputeJobs( style={input} value={target()} onChange={(event) => { - setTarget(event.currentTarget.value as "local" | "modal") + setTarget(event.currentTarget.value as "local" | "modal" | `ssh:${string}`) setReviewed(false) setPlan(undefined) }} > <option value="local">This computer</option> <option value="modal">Modal</option> + <For each={compute()?.ssh_hosts}> + {(host) => ( + <option value={`ssh:${host.id}`} disabled={!host.fingerprint}> + {host.label} · {host.scheduler === "none" ? "SSH" : host.scheduler.toUpperCase()} + {host.fingerprint ? "" : " · test first"} + </option> + )} + </For> </select> </Field> <Field label="Working directory"> @@ -613,9 +646,11 @@ export function ComputeJobs( /> </Field> </div> + </Show> + <Show when={target() !== "local"}> <Field label="Files to upload"> <input - aria-label="Modal upload patterns" + aria-label="Remote upload patterns" style={input} value={uploads()} placeholder="train.py, src/**/*.py, data/sample.csv" @@ -683,7 +718,7 @@ export function ComputeJobs( /> </Field> </div> - <Show when={target() === "local"}> + <Show when={target().startsWith("ssh:")}> <Field label="Queue or partition"> <input aria-label="Queue or partition" @@ -737,7 +772,7 @@ export function ComputeJobs( <Show when={approved() && command().trim()}> <DispatchPreview staged={staged()} /> </Show> - <Show when={target() === "modal" && approved() && plan()}> + <Show when={target() === "modal" && approved() && modalPlan()}> {(value) => ( <div data-testid="modal-plan" style={captureCard}> <div style={cardTitle}> @@ -768,6 +803,37 @@ export function ComputeJobs( </div> )} </Show> + <Show when={target().startsWith("ssh:") && approved() && sshPlan()}> + {(value) => ( + <div data-testid="ssh-plan" style={captureCard}> + <div style={cardTitle}> + <span>SSH approval</span> + <span>{bytes(value().upload_bytes)} upload</span> + </div> + <div style={manifestGrid}> + <span>Host</span> + <strong>{value().label}</strong> + <span>Scheduler</span> + <strong>{value().scheduler === "none" ? "Direct SSH" : value().scheduler.toUpperCase()}</strong> + <span>Host key</span> + <strong>{value().fingerprint}</strong> + <span>Remote folder</span> + <strong>{value().remote_root}</strong> + <span>Inputs</span> + <strong> + {value().uploads.length + ? value() + .uploads.map((file) => file.path) + .join(", ") + : "None"} + </strong> + <span>Outputs</span> + <strong>{value().outputs.length ? value().outputs.join(", ") : "None"}</strong> + </div> + <span style={{ color: "var(--color-warning)", "font-size": "12px" }}>{value().warning}</span> + </div> + )} + </Show> <div style={{ display: "flex", "justify-content": "flex-end", gap: "8px", "padding-top": "2px" }}> <button type="button" style={secondaryButton} onClick={reset}> Cancel @@ -779,7 +845,7 @@ export function ComputeJobs( disabled={!name().trim() || !command().trim()} onClick={() => void review()} > - {target() === "modal" && busy() ? "Preparing plan…" : "Review command"} + {target() !== "local" && busy() ? "Preparing plan…" : "Review command"} </button> </Show> <Show when={approved()}> @@ -898,14 +964,14 @@ export function ComputeJobs( > {job().id} · {duration(job())} </span> - <Show when={!terminal.has(job().status) && job().target.kind !== "ssh"}> + <Show when={!terminal.has(job().status)}> <Action title="Cancel job" onClick={() => void cancel(job())}> <IconStop size={16} /> </Action> </Show> <Show when={ - job().target.kind === "modal" && + (job().target.kind === "modal" || job().target.kind === "ssh") && terminal.has(job().status) && job().lifecycle?.recoverable } @@ -913,7 +979,7 @@ export function ComputeJobs( <button type="button" style={secondaryButton} - title="Retry collecting and delivering outputs from the retained Modal volume" + title="Retry collecting and delivering outputs from the retained remote workspace" onClick={() => void retry(job())} > Retry delivery @@ -921,31 +987,25 @@ export function ComputeJobs( </Show> <Show when={ - job().target.kind === "modal" && terminal.has(job().status) && - !job().lifecycle?.recoverable && - job().lifecycle?.resource === "unknown" + job().lifecycle?.resource === "unknown" && + job().target.kind !== "local" } > <button type="button" style={secondaryButton} - title="Retry stopping the Modal sandbox and releasing its volume" - onClick={() => void cancel(job())} + title="Retry releasing the retained remote workspace" + onClick={() => void release(job())} > - Retry cleanup + Retry release </button> </Show> <Show when={props.manual}> <button type="button" style={secondaryButton} - disabled={job().target.kind === "ssh"} - title={ - job().target.kind !== "ssh" - ? `Rerun this command on ${job().target.kind === "modal" ? "Modal" : "this computer"}` - : "Remote dispatch is unavailable until its full lifecycle passes real-host validation" - } + title={`Rerun this command on ${job().target_label}`} onClick={() => rerun(job())} > Rerun @@ -1294,7 +1354,7 @@ const mark: JSX.CSSProperties = { const title: JSX.CSSProperties = { color: "var(--color-text)", "font-size": "14px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", "letter-spacing": "-0.01em", "line-height": 1.2, } @@ -1331,7 +1391,7 @@ const formHeader: JSX.CSSProperties = { const formTitle: JSX.CSSProperties = { color: "var(--color-text)", "font-size": "14px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", "letter-spacing": "-0.01em", } @@ -1371,7 +1431,7 @@ const advancedToggle: JSX.CSSProperties = { color: "var(--color-text-muted)", "font-family": FONT_SANS, "font-size": "14px", - "font-weight": 550, + "font-weight": "var(--font-weight-emphasis)", padding: "0 10px", "text-align": "left", } @@ -1400,7 +1460,7 @@ const primaryButton: JSX.CSSProperties = { padding: "0 13px", "font-family": FONT_SANS, "font-size": "14px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", transition: "filter 140ms ease, transform 140ms ease", } @@ -1414,7 +1474,7 @@ const secondaryButton: JSX.CSSProperties = { padding: "0 10px", "font-family": FONT_SANS, "font-size": "14px", - "font-weight": 550, + "font-weight": "var(--font-weight-emphasis)", } const empty: JSX.CSSProperties = { @@ -1447,7 +1507,7 @@ const emptyMark: JSX.CSSProperties = { const emptyTitle: JSX.CSSProperties = { color: "var(--color-text)", "font-size": "14px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", "letter-spacing": "-0.01em", } @@ -1467,7 +1527,7 @@ const listHeader: JSX.CSSProperties = { color: "var(--color-text-faint)", "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", } const textButton: JSX.CSSProperties = { @@ -1675,7 +1735,7 @@ const cardTitle: JSX.CSSProperties = { color: "var(--color-text-faint)", "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", } const artifactRow: JSX.CSSProperties = { @@ -1713,7 +1773,7 @@ const exportButton: JSX.CSSProperties = { color: "var(--color-text-muted)", "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 550, + "font-weight": "var(--font-weight-emphasis)", } const logHeader: JSX.CSSProperties = { @@ -1724,7 +1784,7 @@ const logHeader: JSX.CSSProperties = { color: "var(--color-text-faint)", "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", "margin-top": "4px", } diff --git a/frontend/workspace/src/atlas/ComputeJobsAPI.ts b/frontend/workspace/src/atlas/ComputeJobsAPI.ts index 6d85ae86..55f7de1d 100644 --- a/frontend/workspace/src/atlas/ComputeJobsAPI.ts +++ b/frontend/workspace/src/atlas/ComputeJobsAPI.ts @@ -8,6 +8,21 @@ export interface Artifact { size: number sha256: string modified_at: string + artifact_id?: string + version_id?: string + version?: number +} + +export interface Host { + id: string + label: string + host: string + user?: string + port?: number + scheduler: "none" | "slurm" | "pbs" + workdir?: string + fingerprint?: string + concurrency: number } export interface Resources { @@ -82,6 +97,16 @@ export interface Job { sdk: string volume?: string } + ssh?: { + protocol: 1 + host: Host + root: string + cwd: string + fingerprint: string + uploads: { path: string; size: number; sha256: string }[] + upload_bytes: number + approval: string + } } export interface JobInput { @@ -101,7 +126,7 @@ export interface JobInput { approval?: string } -export interface Plan { +export interface ModalPlan { digest: string provider: "modal" app: string @@ -117,6 +142,26 @@ export interface Plan { warning: string } +export interface SshPlan { + digest: string + provider: "ssh" + host_id: string + host: string + label: string + scheduler: "none" | "slurm" | "pbs" + fingerprint: string + command: string + local_cwd: string + remote_root: string + remote_cwd: string + uploads: { path: string; size: number; sha256: string }[] + upload_bytes: number + outputs: string[] + warning: string +} + +export type Plan = ModalPlan | SshPlan + export function stableJobs(previous: Job[] | undefined, next: Job[]) { if (!previous) return next const index = new Map(previous.map((job) => [job.id, job])) @@ -165,12 +210,19 @@ export function createComputeJobsAPI(request: ProjectRequest) { return response.json() as Promise<T> } return { + settings: () => + request("/settings/compute", { cache: "no-store" }).then(async (response) => { + if (!response.ok) + throw new Error((await response.text().catch(() => "")) || `${response.status} ${response.statusText}`) + return response.json() as Promise<{ ssh_hosts: Host[] }> + }), list: () => call<Job[]>("", { cache: "no-store" }), plan: (input: JobInput) => call<Plan>("/plan", { method: "POST", body: JSON.stringify(input) }), start: (input: JobInput) => call<Job>("", { method: "POST", body: JSON.stringify(input) }), log: (id: string) => call<{ log: string }>(`/${id}/log`, { cache: "no-store" }), events: (id: string) => call<{ events: string }>(`/${id}/events`, { cache: "no-store" }), retry: (id: string) => call<Job>(`/${id}/retry`, { method: "POST" }), + release: (id: string) => call<Job>(`/${id}/release`, { method: "POST" }), cancel: (id: string) => call<Job>(`/${id}/cancel`, { method: "POST" }), clear: () => call<{ cleared: number }>("/completed", { method: "DELETE" }), } diff --git a/frontend/workspace/src/atlas/ComputeSurface.css b/frontend/workspace/src/atlas/ComputeSurface.css index 3db39160..9045f0b5 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.css +++ b/frontend/workspace/src/atlas/ComputeSurface.css @@ -1,5 +1,9 @@ .compute-surface { container: compute / inline-size; + --compute-divider: var(--border-weak-base); + --compute-tint: color-mix(in srgb, var(--color-bg-subtle) 56%, var(--color-bg)); + --compute-radius-control: 10px; + --compute-radius-card: 12px; display: flex; min-width: 0; min-height: 0; @@ -11,6 +15,7 @@ color: var(--color-text); font-family: inherit; font-size: 13px; + font-weight: var(--font-weight-regular); line-height: 1.35; } @@ -33,17 +38,21 @@ } .compute-surface .kernel-panel__message { + min-height: 38px; + box-sizing: border-box; margin: 10px 12px 0; - padding: 8px 10px; + padding: 9px 11px; border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-elevated); + border-radius: var(--atlas-radius-xs); + background: var(--color-bg); color: var(--color-text-muted); font-size: 12px; + line-height: 1.45; } .compute-surface .kernel-panel__message--error { border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border)); + background: color-mix(in srgb, var(--color-error) 4%, var(--color-bg)); color: var(--color-error); } @@ -51,29 +60,18 @@ display: flex; min-height: 220px; align-items: center; - justify-content: center; + justify-content: flex-start; flex-direction: column; gap: 7px; - padding: 32px 24px; + padding: 76px 24px 32px; color: var(--color-text-muted); text-align: center; } -.compute-surface .kernel-panel__empty > span { - display: grid; - width: 30px; - height: 30px; - place-items: center; - margin-bottom: 2px; - border: 1px solid var(--color-border); - border-radius: 8px; - color: var(--color-text-faint); -} - .compute-surface .kernel-panel__empty strong { color: var(--color-text); font-size: 13px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .compute-surface .kernel-panel__empty p { @@ -87,48 +85,49 @@ display: flex; min-width: 0; flex-direction: column; + gap: 20px; + padding: 18px 12px 24px; } .compute-surface .kernel-session { min-width: 0; - border-bottom: 1px solid var(--color-border); + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; } .compute-surface .kernel-session__header { display: grid; min-width: 0; - min-height: 44px; + min-height: 36px; box-sizing: border-box; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); align-items: center; gap: 10px; - padding: 8px 14px; - background: var(--color-bg-subtle); + padding: 0 4px 8px; + border-bottom: 0; + background: transparent; color: var(--color-text-muted); - font-size: 11px; + font-size: 12px; } .compute-surface .kernel-session[data-current="true"] .kernel-session__header { - background: color-mix(in srgb, var(--color-bg-elevated) 72%, var(--color-bg)); + background: transparent; } .compute-surface .kernel-session__identity { display: flex; min-width: 0; align-items: center; - gap: 7px; -} - -.compute-surface .kernel-session__identity > span { - color: var(--color-text-faint); - font-size: 11px; + gap: 6px; } .compute-surface .kernel-session__identity strong { overflow: hidden; color: var(--color-text); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); text-overflow: ellipsis; white-space: nowrap; } @@ -136,43 +135,73 @@ .compute-surface .kernel-session__identity em { flex: none; padding: 1px 6px; - border: 1px solid var(--color-border); + border: 0; border-radius: 999px; - color: var(--color-text-faint); - font-size: 10px; + background: var(--color-bg-subtle); + color: var(--color-text-muted); + font-size: 12px; font-style: normal; + line-height: 1.2; } -.compute-surface .kernel-session__header > span { +.compute-surface .kernel-session__summary { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: var(--color-text-muted); font-variant-numeric: tabular-nums; +} + +.compute-surface .kernel-session__summary > span { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.compute-surface .kernel-session__summary > span + span { + position: relative; + padding-left: 10px; +} + +.compute-surface .kernel-session__summary > span + span::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: 2px; + height: 2px; + border-radius: 999px; + background: currentColor; + opacity: 0.55; +} + .compute-surface .kernel-panel__list { display: flex; min-width: 0; flex-direction: column; - gap: 0; + gap: 4px; } .compute-surface .kernel-card { display: grid; min-width: 0; - min-height: 62px; + min-height: 66px; box-sizing: border-box; - grid-template-columns: minmax(136px, 1fr) 54px 76px 58px 52px; + grid-template-columns: minmax(150px, 1fr) minmax(190px, auto) auto; align-items: center; - gap: 10px; - padding: 9px 14px; + gap: 12px; + padding: 10px 12px; border: 0; - border-top: 1px solid var(--color-border); - border-radius: 0; - background: var(--color-bg); + border-radius: var(--compute-radius-card); + background: color-mix(in srgb, var(--color-bg-subtle) 72%, var(--color-bg)); box-shadow: none; + transition: background-color 140ms ease; } -.compute-surface .kernel-card:first-child { - border-top: 0; +.compute-surface .kernel-card:hover { + background: color-mix(in srgb, var(--color-bg-subtle) 92%, var(--color-bg)); } .compute-surface .kernel-card__main { @@ -184,16 +213,15 @@ .compute-surface .kernel-card__language { display: grid; - width: 30px; - height: 30px; + width: 32px; + height: 32px; flex: none; place-items: center; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-elevated); + border-radius: var(--compute-radius-control); + background: color-mix(in srgb, var(--color-focus) 10%, var(--color-bg)); color: var(--color-text-muted); - font-size: 10px; - font-weight: 500; + font-size: 12px; + font-weight: var(--font-weight-medium); letter-spacing: 0; } @@ -207,8 +235,8 @@ .compute-surface .kernel-card__copy strong { overflow: hidden; color: var(--color-text); - font-size: 12px; - font-weight: 500; + font-size: 13px; + font-weight: var(--font-weight-medium); text-overflow: ellipsis; white-space: nowrap; } @@ -219,8 +247,8 @@ align-items: center; gap: 5px; overflow: hidden; - color: var(--color-text-faint); - font-size: 11px; + color: var(--color-text-muted); + font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -251,50 +279,65 @@ .compute-surface .kernel-card__uptime, .compute-surface .kernel-card__metric { + min-width: 0; color: var(--color-text-muted); - font-size: 11px; + font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.compute-surface .kernel-card__metrics { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(54px, auto)); + align-items: center; + gap: 12px; +} + .compute-surface .kernel-card__metric { display: flex; - min-width: 0; flex-direction: column; - gap: 1px; + gap: 2px; } .compute-surface .kernel-card__metric strong { overflow: hidden; color: var(--color-text); - font-size: 11px; - font-weight: 500; + font-size: 12px; + font-weight: var(--font-weight-medium); text-overflow: ellipsis; } .compute-surface .kernel-card__metric small { - color: var(--color-text-faint); - font-size: 9px; + overflow: hidden; + color: var(--color-text-muted); + font-size: 11px; letter-spacing: 0; - line-height: 1; - text-transform: lowercase; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; } .compute-surface .kernel-card__stop { min-width: 48px; - height: 28px; + height: 32px; padding: 0 9px; - border: 1px solid var(--color-border); - border-radius: 7px; + border: 1px solid transparent; + border-radius: var(--compute-radius-control); background: transparent; color: var(--color-text-muted); font: inherit; - font-size: 11px; + font-size: 12px; cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease; } .compute-surface .kernel-card__stop:hover:not(:disabled) { border-color: color-mix(in srgb, var(--color-error) 36%, var(--color-border)); + background: color-mix(in srgb, var(--color-error) 5%, transparent); color: var(--color-error); } @@ -311,22 +354,23 @@ .compute-surface .kernel-card__cell { grid-column: 1 / -1; min-width: 0; - margin: 0 0 1px 39px; + margin: 0 0 2px 41px; overflow: hidden; border: 1px solid var(--color-border); - border-radius: 7px; - background: var(--color-bg-subtle); + border-radius: var(--compute-radius-control); + background: var(--color-bg); } .compute-surface .kernel-card__cell summary { + min-height: 36px; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; - padding: 6px 8px; - color: var(--color-text-faint); - font-size: 10px; + padding: 7px 9px; + color: var(--color-text-muted); + font-size: 12px; cursor: pointer; list-style-position: inside; } @@ -341,13 +385,13 @@ .compute-surface .kernel-card__cell summary strong { color: var(--color-text); - font-size: 11px; - font-weight: 500; + font-size: 12px; + font-weight: var(--font-weight-medium); } .compute-surface .kernel-card__cell summary small { - color: var(--color-text-faint); - font-size: 10px; + color: var(--color-text-muted); + font-size: 12px; } .compute-surface .kernel-card__cell pre { @@ -355,10 +399,11 @@ margin: 0; padding: 8px 10px; overflow: auto; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--compute-divider); + background: var(--compute-tint); color: var(--color-text-muted); font-family: var(--font-mono, monospace); - font-size: 10px; + font-size: 11px; line-height: 1.45; white-space: pre; } @@ -367,12 +412,16 @@ font: inherit; } -.compute-surface .remote-results { - border-top: 8px solid var(--color-bg-subtle); +.compute-surface .remote-job-card { + grid-template-columns: minmax(160px, 1fr) minmax(190px, auto) auto; } -.compute-surface .remote-job-card { - grid-template-columns: minmax(160px, 1fr) 64px minmax(118px, auto) auto; +.compute-surface .remote-job-card__summary { + display: grid; + min-width: 0; + grid-template-columns: 64px minmax(110px, auto); + align-items: center; + gap: 12px; } .compute-surface .remote-job-card__result { @@ -385,15 +434,14 @@ .compute-surface .remote-job-card__result strong { color: var(--color-text); - font-size: 11px; - font-weight: 500; - text-transform: capitalize; + font-size: 12px; + font-weight: var(--font-weight-medium); } .compute-surface .remote-job-card__result small { overflow: hidden; - color: var(--color-text-faint); - font-size: 10px; + color: var(--color-text-muted); + font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } @@ -405,20 +453,25 @@ } .compute-surface .remote-job-card__actions button { - height: 28px; + height: 32px; padding: 0 8px; - border: 1px solid var(--color-border); - border-radius: 7px; + border: 1px solid transparent; + border-radius: var(--compute-radius-control); background: transparent; color: var(--color-text-muted); font: inherit; - font-size: 11px; + font-size: 12px; cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease; } .compute-surface .remote-job-card__actions button:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-border-strong, var(--color-border)); + background: var(--compute-tint); } .compute-surface .remote-job-card__actions button:disabled { @@ -426,16 +479,22 @@ cursor: not-allowed; } +.compute-surface .remote-job-card__actions button:focus-visible, +.compute-surface .kernel-card__cell summary:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + .compute-surface .remote-job-card__warning, .compute-surface .remote-job-card__output { grid-column: 1 / -1; - margin: 0 0 2px 39px; + margin: 0 0 2px 41px; padding: 8px 10px; border: 1px solid var(--color-border); - border-radius: 7px; - background: var(--color-bg-subtle); + border-radius: var(--compute-radius-control); + background: var(--compute-tint); color: var(--color-text-muted); - font-size: 11px; + font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; @@ -449,16 +508,17 @@ @container compute (max-width: 470px) { .compute-surface .kernel-session__header { grid-template-columns: minmax(0, 1fr); - gap: 3px; + gap: 4px; padding-block: 9px; } - .compute-surface .kernel-session__header > span { - padding-left: 18px; + .compute-surface .kernel-session__summary { + justify-content: flex-start; + line-height: 1.35; } .compute-surface .kernel-card { - grid-template-columns: minmax(0, 1fr) auto auto auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px 12px; } @@ -467,35 +527,65 @@ grid-column: 1 / -1; } - .compute-surface .kernel-card__uptime { - padding-left: 39px; + .compute-surface .kernel-card__metrics { + grid-column: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-left: 41px; } .compute-surface .remote-job-card { grid-template-columns: minmax(0, 1fr) auto; } - .compute-surface .remote-job-card__result { - padding-left: 39px; + .compute-surface .remote-job-card__summary { + grid-column: 1; + grid-template-columns: minmax(50px, auto) minmax(0, 1fr); + padding-left: 0; + } + + .compute-surface .remote-job-card__actions { + grid-column: 2; + grid-row: 2; } } @container compute (max-width: 350px) { - .compute-surface .kernel-card { - grid-template-columns: minmax(0, 1fr) auto auto; + .compute-surface .kernel-panel__sessions { + padding-inline: 8px; } - .compute-surface .kernel-card__uptime { + .compute-surface .kernel-session__summary > span:nth-child(n + 2) { display: none; } - .compute-surface .remote-job-card__result { - grid-column: 1 / -1; + .compute-surface .kernel-card__metrics { + padding-left: 0; + } + + .compute-surface .kernel-card__metric small { + font-size: 10px; + } + + .compute-surface .remote-job-card__summary { + grid-template-columns: minmax(46px, auto) minmax(0, 1fr); + } + + .compute-surface .remote-job-card__result small { + white-space: normal; + } +} + +@media (pointer: coarse) { + .compute-surface .kernel-card__stop, + .compute-surface .remote-job-card__actions button, + .compute-surface .kernel-card__cell summary { + min-height: 44px; } } @media (prefers-reduced-motion: reduce) { .compute-surface * { scroll-behavior: auto; + transition-duration: 0.01ms !important; } } diff --git a/frontend/workspace/src/atlas/ComputeSurface.test.ts b/frontend/workspace/src/atlas/ComputeSurface.test.ts index c4f3010e..b7ac5df0 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.test.ts +++ b/frontend/workspace/src/atlas/ComputeSurface.test.ts @@ -50,6 +50,7 @@ describe("compute surface", () => { expect(host.querySelector('[aria-label="Compute"]')).not.toBeNull() expect(host.querySelector('[data-compute-child="strip"]')).not.toBeNull() expect(host.querySelector('[data-compute-child="kernels"]')).not.toBeNull() + expect(host.querySelector('[aria-label="Atlas Compute preview"]')).toBeNull() expect(host.querySelector('[role="tablist"]')).toBeNull() }) @@ -60,6 +61,9 @@ describe("compute surface", () => { expect(source).not.toContain("New kernel") expect(source).not.toContain("onEnsureSession") expect(source).not.toContain('role="tab"') + expect(source).not.toContain("href=") + expect(source).not.toContain("Coming soon") + expect(source).not.toContain("Atlas Compute preview") expect(source).toContain("Compute only reflects what is live") expect(source).toContain("governed remote GPU jobs") expect(source).toContain("Completed remote results stay") @@ -72,7 +76,13 @@ describe("compute surface", () => { expect(css).toContain("container: compute / inline-size") expect(css).toContain("@container compute (max-width: 470px)") expect(css).toContain("@container compute (max-width: 350px)") + expect(css).toContain(".compute-surface .kernel-card__metrics") + expect(css).toContain("grid-template-columns: minmax(0, 1fr) auto") + expect(css).toContain(".compute-surface .remote-job-card__summary") expect(host).toContain("@container compute (max-width: 500px)") + expect(css).toContain("--compute-divider: var(--border-weak-base)") + expect(css).toContain("--compute-radius-card: 12px") + expect(css).toContain("@media (pointer: coarse)") expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}/) expect(host).not.toMatch(/#[0-9a-fA-F]{3,8}/) }) diff --git a/frontend/workspace/src/atlas/DisconnectedPanel.test.ts b/frontend/workspace/src/atlas/DisconnectedPanel.test.ts new file mode 100644 index 00000000..c56cc7de --- /dev/null +++ b/frontend/workspace/src/atlas/DisconnectedPanel.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { fileURLToPath } from "node:url" + +const file = fileURLToPath(new URL("./DisconnectedPanel.tsx", import.meta.url)) + +test("connection recovery uses the shared button system and consistent action casing", async () => { + const source = await Bun.file(file).text() + + expect(source).toContain('import { Button } from "@synsci/ui/button"') + expect(source).toContain('"Checking…" : "Retry Now"') + expect(source).toContain("Switch Server") + expect(source).not.toContain('"checking…" : "retry now"') + expect(source).not.toMatch(/>\s*switch server\s*</) +}) diff --git a/frontend/workspace/src/atlas/DisconnectedPanel.tsx b/frontend/workspace/src/atlas/DisconnectedPanel.tsx index 4004c246..10778588 100644 --- a/frontend/workspace/src/atlas/DisconnectedPanel.tsx +++ b/frontend/workspace/src/atlas/DisconnectedPanel.tsx @@ -1,8 +1,9 @@ import { Show, type JSX } from "solid-js" +import { Button } from "@synsci/ui/button" import { useDialog } from "@synsci/ui/context/dialog" import { useServer } from "@/context/server" import { DialogSelectServer } from "@/components/dialog-select-server" -import { FONT_MONO, FONT_SANS } from "@/styles/tokens" +import { FONT_CODE, FONT_SANS } from "@/styles/tokens" /** * Banner shown when the local openscience server is confirmed unreachable @@ -27,6 +28,7 @@ export function DisconnectedPanel(): JSX.Element { background: "var(--color-error-muted, rgba(239,68,68,0.15))", "border-bottom": "1px solid var(--color-error, #ef4444)", "flex-shrink": 0, + "flex-wrap": "wrap", }} > <span @@ -38,16 +40,21 @@ export function DisconnectedPanel(): JSX.Element { "flex-shrink": 0, }} /> - <div style={{ flex: 1, "min-width": 0 }}> + <div style={{ flex: "1 1 320px", "min-width": 0 }}> <div - style={{ "font-family": FONT_SANS, "font-size": "12.5px", "font-weight": 500, color: "var(--color-text)" }} + style={{ + "font-family": FONT_SANS, + "font-size": "12.5px", + "font-weight": "var(--font-weight-medium)", + color: "var(--color-text)", + }} > Can't reach your local OpenScience server </div> <div style={{ - "font-family": FONT_MONO, - "font-size": "10.5px", + "font-family": FONT_SANS, + "font-size": "11.5px", color: "var(--color-text-muted)", overflow: "hidden", "text-overflow": "ellipsis", @@ -55,50 +62,35 @@ export function DisconnectedPanel(): JSX.Element { }} > {server.name} ·{" "} - <Show when={server.isLocal()} fallback="check the server URL or switch servers"> - start it with <code>openscience serve</code> + <Show when={server.isLocal()} fallback="Check the server URL or switch servers"> + Start it with <code style={{ "font-family": FONT_CODE }}>openscience serve</code> </Show> <Show when={server.failures() > 1}> · {server.failures()} failed checks</Show> </div> </div> - <button + <Button type="button" + size="large" + variant="primary" disabled={server.checking()} onClick={() => void server.refresh()} style={{ - all: "unset", - cursor: server.checking() ? "wait" : "pointer", - padding: "6px 12px", - "border-radius": "4px", - border: "1px solid var(--color-error, #ef4444)", - background: "var(--color-error, #ef4444)", - "font-family": FONT_MONO, - "font-size": "11px", - color: "white", - opacity: server.checking() ? 0.7 : 1, "flex-shrink": 0, }} > - {server.checking() ? "checking…" : "retry now"} - </button> - <button + {server.checking() ? "Checking…" : "Retry Now"} + </Button> + <Button type="button" + size="large" + variant="secondary" onClick={() => dialog.show(() => <DialogSelectServer />)} style={{ - all: "unset", - cursor: "pointer", - padding: "6px 12px", - "border-radius": "4px", - border: "1px solid var(--color-border-strong)", - background: "var(--color-surface-solid)", - "font-family": FONT_MONO, - "font-size": "11px", - color: "var(--color-text)", "flex-shrink": 0, }} > - switch server - </button> + Switch Server + </Button> </div> </Show> ) diff --git a/frontend/workspace/src/atlas/DispatchPreview.tsx b/frontend/workspace/src/atlas/DispatchPreview.tsx index 5dd81db4..0cdc70d6 100644 --- a/frontend/workspace/src/atlas/DispatchPreview.tsx +++ b/frontend/workspace/src/atlas/DispatchPreview.tsx @@ -119,7 +119,9 @@ export function DispatchPreview(props: { staged: Staged }): JSX.Element { {(row) => ( <> <span>{row.label}</span> - <strong style={{ "font-weight": 500, color: "var(--color-text)" }}>{row.value}</strong> + <strong style={{ "font-weight": "var(--font-weight-medium)", color: "var(--color-text)" }}> + {row.value} + </strong> </> )} </For> @@ -143,7 +145,7 @@ const heading: JSX.CSSProperties = { color: "var(--color-text)", "font-family": FONT_SANS, "font-size": "12px", - "font-weight": 600, + "font-weight": "var(--font-weight-emphasis)", } const box: JSX.CSSProperties = { diff --git a/frontend/workspace/src/atlas/FdaBanner.css b/frontend/workspace/src/atlas/FdaBanner.css index 1729f6d8..339801b2 100644 --- a/frontend/workspace/src/atlas/FdaBanner.css +++ b/frontend/workspace/src/atlas/FdaBanner.css @@ -91,7 +91,7 @@ align-items: start; gap: 0.7rem; border: 1px solid color-mix(in srgb, var(--color-warning) 22%, var(--border-weak-base)); - border-radius: var(--radius-lg); + border-radius: var(--atlas-radius-lg); padding: 0.8rem 0.9rem; background: color-mix(in srgb, var(--color-warning) 6%, var(--surface-base)); } diff --git a/frontend/workspace/src/atlas/FileExplorer.css b/frontend/workspace/src/atlas/FileExplorer.css new file mode 100644 index 00000000..369e3cea --- /dev/null +++ b/frontend/workspace/src/atlas/FileExplorer.css @@ -0,0 +1,132 @@ +.external-file-access { + min-height: 0; + flex: 1; + display: grid; + place-items: center; + padding: clamp(24px, 6vw, 64px); + overflow-y: auto; + background: var(--color-bg); + font-family: var(--font-family-sans); +} + +.external-file-access__content { + width: min(100%, 520px); + display: flex; + flex-direction: column; + gap: 20px; +} + +.external-file-access__icon { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); +} + +.external-file-access__copy { + display: flex; + flex-direction: column; + gap: 8px; +} + +.external-file-access__copy h2 { + margin: 0; + color: var(--color-text); + font-size: 20px; + font-weight: var(--font-weight-medium); + line-height: 1.25; + letter-spacing: -0.012em; +} + +.external-file-access__copy p { + margin: 0; + color: var(--color-text-muted); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1.55; +} + +.external-file-access__copy strong { + color: var(--color-text); + font-weight: var(--font-weight-medium); +} + +.external-file-access__field { + display: flex; + flex-direction: column; + gap: 7px; + padding-top: 2px; + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); +} + +.external-file-access__field > span { + color: var(--color-text); + font-size: 13px; + font-weight: var(--font-weight-medium); +} + +.external-file-access__field [data-slot="select-select-trigger"] { + width: 100%; + min-height: 40px; + justify-content: space-between; + border-radius: var(--radius-md); +} + +.external-file-access__field small { + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.45; +} + +.external-file-access__notice { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.5; +} + +.external-file-access__notice[data-tone="critical"] { + border-color: color-mix(in srgb, var(--color-error) 36%, var(--color-border)); + background: color-mix(in srgb, var(--color-error) 6%, transparent); + color: var(--color-error); +} + +.external-file-access__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +@media (max-width: 560px) { + .external-file-access { + place-items: start stretch; + padding: 28px 20px; + } + + .external-file-access__actions { + flex-direction: column-reverse; + align-items: stretch; + } + + .external-file-access__actions [data-component="button"] { + width: 100%; + } +} + +@media (pointer: coarse) { + .external-file-access__field [data-slot="select-select-trigger"] { + min-height: 44px; + } +} diff --git a/frontend/workspace/src/atlas/FileExplorer.test.ts b/frontend/workspace/src/atlas/FileExplorer.test.ts index 8fb67211..e10210e4 100644 --- a/frontend/workspace/src/atlas/FileExplorer.test.ts +++ b/frontend/workspace/src/atlas/FileExplorer.test.ts @@ -34,8 +34,10 @@ describe("file explorer surface", () => { expect(value).toContain("export function ExternalFileAccess") expect(value).toContain('aria-label="File access required"') - expect(value).toContain("will not silently change the project root") + expect(value).toContain("outside this project") expect(value).toContain("findFilesystemGrant") - expect(value).toContain("Request access") + expect(value).toContain("Connect folder") + expect(value).toContain('class="external-file-access"') + expect(value).not.toContain('"font-weight": 600') }) }) diff --git a/frontend/workspace/src/atlas/FileExplorer.tsx b/frontend/workspace/src/atlas/FileExplorer.tsx index 914212d0..3f9676a4 100644 --- a/frontend/workspace/src/atlas/FileExplorer.tsx +++ b/frontend/workspace/src/atlas/FileExplorer.tsx @@ -1,10 +1,10 @@ import { createMemo, createResource, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" +import { Button } from "@synsci/ui/button" import { Select } from "@synsci/ui/select" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { FONT_SANS } from "@/styles/tokens" import type { ContextFile } from "@/atlas/store/ui" import { FileView } from "@/atlas/FilePreview" import { IconFolder } from "@/atlas/shared/Icon" @@ -19,6 +19,7 @@ import { type FilesystemSnapshot, } from "@/atlas/file-sources" import type { ProjectRequest } from "@/utils/openscience-fetch" +import "./FileExplorer.css" interface ConnectInput { path: string @@ -106,42 +107,28 @@ export function ExternalFileAccess(props: { file: ContextFile; active: boolean; )} </Match> <Match when={!grant()}> - <div - role="region" - aria-label="File access required" - style={{ - flex: 1, - "min-height": 0, - padding: "28px", - display: "flex", - "flex-direction": "column", - "justify-content": "center", - gap: "16px", - background: "var(--color-bg-subtle)", - "font-family": FONT_SANS, - }} - > - <span style={requestIcon()}> - <IconFolder size={24} strokeWidth={1.4} /> - </span> - <div> - <h2 style={requestTitle()}>Connect a folder to open {props.file.name}</h2> - <p style={requestCopy()}> - This file is outside the session files. OpenScience will not silently change the project root or read it - without an approved folder grant. - </p> - </div> - <Show - when={sessionID()} - fallback={ - <p role="status" style={alert()}> - Start a research session to request access. + <div class="external-file-access" role="region" aria-label="File access required"> + <div class="external-file-access__content"> + <span class="external-file-access__icon" aria-hidden="true"> + <IconFolder size={22} strokeWidth={1.35} /> + </span> + <div class="external-file-access__copy"> + <h2>Connect a folder</h2> + <p> + <strong>{props.file.name}</strong> is outside this project. Choose the access OpenScience needs; the + folder will stay connected to this project until you remove it. </p> - } - > - <div style={fieldGrid()}> - <div style={field()}> - <span>Access</span> + </div> + <Show + when={sessionID()} + fallback={ + <p class="external-file-access__notice" role="status"> + Start a research session before connecting an external folder. + </p> + } + > + <label class="external-file-access__field"> + <span>Folder access</span> <Select aria-label="External file access" options={accessOptions} @@ -150,106 +137,35 @@ export function ExternalFileAccess(props: { file: ContextFile; active: boolean; label={(option) => option.label} onSelect={(option) => option && setState("access", option.value)} variant="secondary" - triggerStyle={selectTrigger()} + triggerVariant="settings" /> - </div> - </div> - <Show when={state.error ?? (snapshot.error ? errorMessage(snapshot.error) : undefined)}> - {(message) => ( - <p role="alert" style={alert()}> - {message()} - </p> - )} + <small> + {state.access === "write" + ? "OpenScience can read, create, and update files in this folder." + : "OpenScience can inspect files but cannot change them."} + </small> + </label> + <Show when={state.error ?? (snapshot.error ? errorMessage(snapshot.error) : undefined)}> + {(message) => ( + <p class="external-file-access__notice" data-tone="critical" role="alert"> + {message()} + </p> + )} + </Show> </Show> - <button type="button" onClick={request} disabled={state.busy} style={primary()}> - {state.busy ? "Requesting…" : "Request access"} - </button> - </Show> - <button type="button" onClick={props.onClose} style={secondary()}> - Back to Files - </button> + <div class="external-file-access__actions"> + <Button type="button" variant="ghost" size="large" onClick={props.onClose}> + Back to files + </Button> + <Show when={sessionID()}> + <Button type="button" variant="primary" size="large" onClick={request} disabled={state.busy}> + {state.busy ? "Connecting…" : "Connect folder"} + </Button> + </Show> + </div> + </div> </div> </Match> </Switch> ) } - -const field = (): JSX.CSSProperties => ({ - display: "flex", - "flex-direction": "column", - gap: "5px", - color: "var(--color-text-muted)", - "font-size": "11.5px", -}) - -const fieldGrid = (): JSX.CSSProperties => ({ - display: "grid", - "grid-template-columns": "minmax(0, 1fr)", - gap: "8px", -}) - -const selectTrigger = (): JSX.CSSProperties => ({ - width: "100%", - "min-height": "36px", - "justify-content": "space-between", - "border-radius": "6px", -}) - -const primary = (): JSX.CSSProperties => ({ - border: "1px solid var(--color-text)", - "border-radius": "8px", - background: "var(--color-text)", - color: "var(--color-bg)", - "min-height": "36px", - padding: "0 12px", - cursor: "pointer", - "font-size": "12px", - "font-weight": 600, -}) - -const secondary = (): JSX.CSSProperties => ({ - border: "1px solid var(--color-border)", - "border-radius": "8px", - background: "transparent", - color: "var(--color-text-muted)", - "min-height": "36px", - padding: "0 12px", - cursor: "pointer", - "font-size": "12px", -}) - -const alert = (): JSX.CSSProperties => ({ - margin: 0, - padding: "9px 10px", - "border-radius": "8px", - background: "var(--color-error-subtle, var(--color-bg-subtle))", - color: "var(--color-error, var(--color-text))", - "font-size": "11.5px", - "line-height": 1.45, -}) - -const requestIcon = (): JSX.CSSProperties => ({ - width: "48px", - height: "48px", - "border-radius": "12px", - display: "inline-flex", - "align-items": "center", - "justify-content": "center", - color: "var(--color-text-muted)", - background: "var(--color-surface-solid)", - border: "1px solid var(--color-border)", -}) - -const requestTitle = (): JSX.CSSProperties => ({ - margin: 0, - color: "var(--color-text)", - "font-size": "17px", - "line-height": 1.3, -}) - -const requestCopy = (): JSX.CSSProperties => ({ - margin: "8px 0 0", - color: "var(--color-text-muted)", - "font-size": "12.5px", - "line-height": 1.55, -}) diff --git a/frontend/workspace/src/atlas/FilePreview.css b/frontend/workspace/src/atlas/FilePreview.css index 04882afa..c667a636 100644 --- a/frontend/workspace/src/atlas/FilePreview.css +++ b/frontend/workspace/src/atlas/FilePreview.css @@ -22,13 +22,13 @@ z-index: 2; display: flex; min-width: 0; - min-height: 48px; + min-height: 50px; flex: none; align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 10px; - border-bottom: 1px solid var(--color-border); + padding: 8px 12px; + border: 0; background: var(--color-surface-solid); } @@ -53,7 +53,7 @@ overflow: hidden; color: var(--color-text); font-size: 14px; - font-weight: 550; + font-weight: var(--font-weight-emphasis); letter-spacing: -0.006em; line-height: 1.35; text-overflow: ellipsis; @@ -99,9 +99,9 @@ flex: none; align-items: center; padding: 2px; - border: 1px solid var(--color-border); - border-radius: 9px; - background: color-mix(in srgb, var(--color-bg-subtle) 78%, transparent); + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); } .atlas-file-mode, @@ -110,14 +110,15 @@ .atlas-file-close { appearance: none; border: 0; - border-radius: 7px; + border-radius: var(--atlas-radius-xs); font: inherit; line-height: 1; cursor: pointer; transition: - border-color 120ms ease, - background-color 120ms ease, - color 120ms ease; + border-color 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease, + color 140ms ease; } .atlas-file-mode { @@ -126,7 +127,7 @@ background: transparent; color: var(--color-text-faint); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .atlas-file-mode:hover:not(:disabled) { @@ -135,18 +136,18 @@ .atlas-file-mode.is-active { background: var(--color-surface-solid); - box-shadow: 0 0 0 1px var(--color-border); + box-shadow: var(--atlas-shadow-xs); color: var(--color-text); } .atlas-file-button, .atlas-file-action { min-height: 32px; - border: 1px solid var(--color-border); - background: var(--color-surface-solid); + border: 0; + background: var(--color-bg-subtle); color: var(--color-text-muted); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .atlas-file-button { @@ -154,7 +155,6 @@ } .atlas-file-button.is-primary { - border-color: var(--color-accent); background: var(--color-accent); color: var(--color-on-accent); } @@ -169,13 +169,11 @@ .atlas-file-button:hover:not(:disabled), .atlas-file-action:hover:not(:disabled), .atlas-file-close:hover:not(:disabled) { - border-color: var(--color-border-strong); background: var(--color-bg-elevated); color: var(--color-text); } .atlas-file-button.is-primary:hover:not(:disabled) { - border-color: var(--color-accent-hover); background: var(--color-accent-hover); color: var(--color-on-accent); } @@ -224,6 +222,14 @@ min-width: 0; min-height: 0; overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.atlas-file-scroll.is-managed-scroll, +.atlas-file-scroll.is-editor-scroll { + display: flex; + overflow: hidden; } .atlas-file-loading { @@ -239,8 +245,9 @@ overflow: hidden; height: 12px; margin-bottom: 13px; - border-radius: var(--radius); + border-radius: var(--atlas-radius-md); background: var(--color-bg-elevated); + animation: atlas-file-loading 1.1s ease-in-out infinite alternate; } .atlas-file-loading-heading { @@ -258,28 +265,13 @@ margin-bottom: 20px; } -.atlas-file-loading-heading::after, -.atlas-file-loading-line::after { - display: block; - width: 45%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--color-text-faint) 10%, transparent), - transparent - ); - content: ""; - animation: atlas-file-loading 1.25s ease-in-out infinite; -} - @keyframes atlas-file-loading { from { - transform: translateX(-120%); + opacity: 0.45; } to { - transform: translateX(320%); + opacity: 0.82; } } @@ -302,7 +294,7 @@ margin: 9px 0 4px; color: var(--color-text); font-size: 16px; - font-weight: 600; + font-weight: var(--font-weight-emphasis); } .atlas-file-error p { @@ -324,7 +316,7 @@ box-sizing: border-box; width: min(100%, 820px); min-height: 100%; - padding: clamp(30px, 5vw, 52px) clamp(24px, 5vw, 48px) 64px; + padding: clamp(30px, 5cqi, 52px) clamp(20px, 5cqi, 48px) 64px; margin: 0 auto; background: var(--color-surface-solid); } @@ -341,13 +333,14 @@ .atlas-file-document .atlas-md h4 { scroll-margin-top: 24px; color: var(--color-text); - font-weight: 600; + font-weight: var(--font-weight-emphasis); letter-spacing: -0.022em; + text-wrap: balance; } .atlas-file-document .atlas-md h1 { margin: 0 0 22px; - font-size: clamp(24px, 3vw, 30px); + font-size: clamp(24px, 3cqi, 30px); line-height: 1.18; } @@ -371,6 +364,7 @@ .atlas-file-document .atlas-md p { margin: 0 0 13px; + text-wrap: pretty; } .atlas-file-document .atlas-md ul, @@ -410,9 +404,58 @@ max-width: 100%; height: auto; margin: 22px auto; - border: 1px solid var(--color-border); - border-radius: 10px; - background: var(--color-bg); + border: 0; + border-radius: var(--atlas-radius-sm); + background: transparent; +} + +.atlas-file-document-lead { + margin-bottom: 30px; +} + +.atlas-file-document-lead[data-align="center"] { + text-align: center; +} + +.atlas-file-document-lead[data-align="right"] { + text-align: right; +} + +.atlas-file-document-lead .atlas-md img { + margin-inline: auto; +} + +.atlas-file-document-lead[data-align="right"] .atlas-md img { + margin-right: 0; +} + +.atlas-file-document-lead[data-align="left"] .atlas-md img { + margin-left: 0; +} + +.atlas-file-document-lead .atlas-md p:has(a > img), +.atlas-file-document-lead .atlas-md .atlas-file-badges { + display: flex; + align-items: center; + justify-content: inherit; + gap: 6px; + flex-wrap: wrap; +} + +.atlas-file-document-lead[data-align="center"] .atlas-md p:has(a > img), +.atlas-file-document-lead[data-align="center"] .atlas-md .atlas-file-badges { + justify-content: center; +} + +.atlas-file-document-lead[data-align="right"] .atlas-md p:has(a > img), +.atlas-file-document-lead[data-align="right"] .atlas-md .atlas-file-badges { + justify-content: flex-end; +} + +.atlas-file-document-lead .atlas-md p:has(a > img) img, +.atlas-file-document-lead .atlas-md .atlas-file-badges img { + margin: 0; + border-radius: 3px; } .atlas-file-document .atlas-md table { @@ -441,6 +484,18 @@ min-height: 420px; } +.atlas-file-pdf { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + padding: 0; +} + +.atlas-file-pdf .pdf-viewer { + height: 100%; +} + .atlas-file-image { display: grid; width: 100%; @@ -455,9 +510,9 @@ max-height: 100%; object-fit: contain; border: 1px solid var(--color-border); - border-radius: var(--radius); + border-radius: var(--atlas-radius-md); background: var(--color-surface-solid); - box-shadow: var(--shadow-xs); + box-shadow: var(--atlas-shadow-xs); } .atlas-file-html { @@ -489,6 +544,8 @@ .atlas-file-source-editor { display: block; + min-height: 0; + flex: 1; resize: none; border: 0; outline-offset: -3px; @@ -496,6 +553,17 @@ white-space: pre; } +.atlas-file-source-editor.is-prose-editor { + padding: clamp(24px, 4cqi, 40px) max(20px, calc((100cqi - 820px) / 2)) 64px; + line-height: 1.75; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.atlas-file-source-editor::selection { + background: color-mix(in srgb, var(--color-accent) 22%, transparent); +} + .atlas-file-truncated pre { margin: 0; overflow-wrap: anywhere; @@ -506,7 +574,7 @@ padding: 9px 11px; margin-bottom: 13px; border: 1px solid var(--color-border); - border-radius: 8px; + border-radius: var(--atlas-radius-xs); background: var(--color-surface-solid); color: var(--color-text-muted); font-family: var(--font-sans); @@ -657,6 +725,20 @@ } } +@media (pointer: coarse) { + .atlas-file-mode, + .atlas-file-button, + .atlas-file-action, + .atlas-file-close { + min-height: 44px; + } + + .atlas-file-close, + .atlas-file-action { + min-width: 44px; + } +} + @media (prefers-reduced-motion: reduce) { .atlas-file-mode, .atlas-file-button, @@ -665,8 +747,8 @@ transition: none; } - .atlas-file-loading-heading::after, - .atlas-file-loading-line::after { + .atlas-file-loading-heading, + .atlas-file-loading-line { animation: none; } } diff --git a/frontend/workspace/src/atlas/FilePreview.tsx b/frontend/workspace/src/atlas/FilePreview.tsx index ea0bfd3a..8df536ea 100644 --- a/frontend/workspace/src/atlas/FilePreview.tsx +++ b/frontend/workspace/src/atlas/FilePreview.tsx @@ -1,4 +1,15 @@ -import { createSignal, createEffect, createMemo, onMount, onCleanup, type JSX, Show, Switch, Match } from "solid-js" +import { + createSignal, + createEffect, + createMemo, + onMount, + onCleanup, + untrack, + type JSX, + Show, + Switch, + Match, +} from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" @@ -22,9 +33,11 @@ import type { ArtifactInspection } from "@/science/renderers" import { toast } from "@/atlas/Toast" import { IconFile } from "@/atlas/shared/Icon" import { FileToolbar } from "@/atlas/FileToolbar" -import { describeFile, readFile, type FileData, type FileKind } from "@/atlas/file-viewer" +import { describeFile, readFile, reconcileSavedDraft, type FileData, type FileKind } from "@/atlas/file-viewer" import { LANG, extension as ext } from "@/atlas/files/artifact-thumb" import { assetUrl } from "@/utils/markdown-assets" +import { recoverFileDraft, rememberFileDraft } from "@/atlas/file-drafts" +import { splitAlignedMarkdown } from "@/atlas/FilePreviewMarkdown" import "./FilePreview.css" /** @@ -77,6 +90,7 @@ export function FileView(props: { onClose?: () => void active?: boolean writable?: boolean + onDirtyChange?: (dirty: boolean) => void }): JSX.Element { const sdk = useSDK() const sync = useSync() @@ -99,6 +113,7 @@ export function FileView(props: { createEffect(() => { const dir = directory() const path = props.path + const activeSession = untrack(sessionID) view.refresh const id = ++request.current setView({ @@ -119,7 +134,7 @@ export function FileView(props: { // Keep the project directory as the backend instance boundary. External // absolute paths remain absolute and require a session filesystem grant. void readFile(async () => { - const response = await sdk.client.file.read({ path, sessionID: sessionID() }) + const response = await sdk.client.file.read({ path, sessionID: activeSession }) const envelope = response as unknown as { data?: FileData } return envelope.data ?? (response as unknown as FileData) }).then((result) => { @@ -134,7 +149,7 @@ export function FileView(props: { status: "ready", data, error: undefined, - draft: text, + draft: recoverFileDraft(dir, path, text), saved: text, }) }) @@ -151,6 +166,11 @@ export function FileView(props: { const b64 = () => data()?.content ?? "" const dataUrl = () => `data:${mime() || "application/octet-stream"};base64,${b64()}` const dirty = () => view.draft !== view.saved + createEffect(() => props.onDirtyChange?.(dirty())) + createEffect(() => { + if (view.status !== "ready") return + rememberFileDraft(directory(), props.path, view.draft, view.saved) + }) const scientific = createMemo(() => (isBinary() ? undefined : detectScientificFile(e(), view.draft))) const biological = createMemo(() => (isBinary() ? undefined : detectBiologicalFormat(e()))) const binaryScience = createMemo(() => detectBinaryScienceFormat(e())) @@ -215,6 +235,7 @@ export function FileView(props: { inspection: view.inspection, }), ) + const markdown = createMemo(() => splitAlignedMarkdown(view.draft)) createEffect(() => { const current = context() @@ -257,7 +278,11 @@ export function FileView(props: { : undefined if (request.current !== id) return const next = typeof saved === "string" ? saved : content - setView({ draft: next, saved: next, saving: false, saveError: undefined }) + setView({ + ...reconcileSavedDraft(view.draft, content, next), + saving: false, + saveError: undefined, + }) toast.success("saved", title) } catch (error) { if (request.current !== id) return @@ -273,6 +298,10 @@ export function FileView(props: { const [archiving, setArchiving] = createSignal(false) const artifact = async () => { if (archiving()) return + if (dirty()) { + toast.info("save file first", "Save your changes before creating an immutable artifact version.") + return + } const session = sessionID() if (!session) { toast.error("artifact unavailable", "Open this file inside a research session to save artifacts.") @@ -335,6 +364,7 @@ export function FileView(props: { location={location()} description={description()} source={view.source} + sourceLabel={description().source && props.writable !== false ? "Edit" : undefined} dirty={dirty()} saving={view.saving} writable={props.writable} @@ -384,7 +414,13 @@ export function FileView(props: { </section> } > - <div class="atlas-scroll atlas-file-scroll"> + <div + class="atlas-scroll atlas-file-scroll" + classList={{ + "is-managed-scroll": !view.source && (kind() === "table" || kind() === "pdf"), + "is-editor-scroll": view.source, + }} + > <Switch> <Match when={truncated()}> <div class="atlas-file-source atlas-file-truncated"> @@ -413,7 +449,21 @@ export function FileView(props: { {/* ordinary Markdown opens as a quiet document */} <Match when={kind() === "markdown" && !view.source && !manuscript()}> <article class="atlas-file-document"> - <Markdown class="atlas-md" text={view.draft} resolveImage={image} /> + <Show + when={markdown().lead} + fallback={<Markdown class="atlas-md" text={view.draft} resolveImage={image} />} + > + {(lead) => ( + <> + <div class="atlas-file-document-lead" data-align={lead().alignment}> + <Markdown class="atlas-md" text={lead().text} resolveImage={image} /> + </div> + <Show when={markdown().rest}> + {(rest) => <Markdown class="atlas-md" text={rest()} resolveImage={image} />} + </Show> + </> + )} + </Show> </article> </Match> @@ -473,7 +523,7 @@ export function FileView(props: { {/* pdf */} <Match when={kind() === "pdf"}> <div class="atlas-file-pdf"> - <PdfViewer kind="pdf" data={{ base64: b64(), maxPages: 40 }} height={100000} /> + <PdfViewer kind="pdf" data={{ base64: b64(), maxPages: 40 }} /> </div> </Match> @@ -529,8 +579,15 @@ export function FileView(props: { value={view.draft} readOnly={props.writable === false} spellcheck={false} + wrap={kind() === "markdown" ? "soft" : "off"} onInput={(event) => setView({ draft: event.currentTarget.value, saveError: undefined })} class="atlas-scroll atlas-file-source-editor" + classList={{ "is-prose-editor": kind() === "markdown" }} + onKeyDown={(event) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return + event.preventDefault() + void save() + }} /> </Match> <Match when={kind() === "code"}> diff --git a/frontend/workspace/src/atlas/FilePreviewMarkdown.test.ts b/frontend/workspace/src/atlas/FilePreviewMarkdown.test.ts new file mode 100644 index 00000000..d42e913b --- /dev/null +++ b/frontend/workspace/src/atlas/FilePreviewMarkdown.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { splitAlignedMarkdown } from "./FilePreviewMarkdown" + +describe("Markdown preview alignment blocks", () => { + test("extracts a leading GitHub-style centered hero for normal Markdown parsing", () => { + const result = splitAlignedMarkdown(` +<div align="center"> + +![CI](badge.svg) + +<br/> + +[![npm](npm.svg)](https://example.com) +[![docs](docs.svg)](https://docs.example.com) + +### Research workbench + +</div> + +--- + +Body copy. +`) + + expect(result.lead).toEqual({ + alignment: "center", + text: `![CI](badge.svg) + +<p class="atlas-file-badges"><a href="https://example.com"><img src="npm.svg" alt="npm"></a> +<a href="https://docs.example.com"><img src="docs.svg" alt="docs"></a></p> + +### Research workbench`, + }) + expect(result.rest).toBe("---\n\nBody copy.\n") + }) + + test("escapes badge attributes before the sanitized Markdown renderer receives them", () => { + const result = splitAlignedMarkdown(`<div align="center"> +[![A&B](https://img.example/a.svg?x=1&y=2)](https://example.com/?a=1&b=2) +</div>`) + + expect(result.lead?.text).toContain('alt="A&B"') + expect(result.lead?.text).toContain('src="https://img.example/a.svg?x=1&y=2"') + expect(result.lead?.text).toContain('href="https://example.com/?a=1&b=2"') + }) + + test("does not reinterpret unsupported or non-leading HTML", () => { + const unsafe = '<div align="justify">**text**</div>' + const nested = 'Intro\n\n<div align="center">**text**</div>' + + expect(splitAlignedMarkdown(unsafe)).toEqual({ rest: unsafe }) + expect(splitAlignedMarkdown(nested)).toEqual({ rest: nested }) + }) +}) diff --git a/frontend/workspace/src/atlas/FilePreviewMarkdown.ts b/frontend/workspace/src/atlas/FilePreviewMarkdown.ts new file mode 100644 index 00000000..8874090e --- /dev/null +++ b/frontend/workspace/src/atlas/FilePreviewMarkdown.ts @@ -0,0 +1,52 @@ +/** + * GitHub READMEs commonly wrap a hero in `<div align="center">`. CommonMark + * parsers treat everything inside that HTML block as literal text, which leaves + * badges and links unreadable in a preview. Extract only this finite, leading + * alignment form; FilePreview sends both pieces through the same sanitized + * Markdown renderer as every other document. + */ +export function splitAlignedMarkdown(text: string): { + lead?: { alignment: "left" | "center" | "right"; text: string } + rest: string +} { + const match = /^\s*<div\s+align=["'](left|center|right)["']\s*>\s*([\s\S]*?)\s*<\/div>\s*/i.exec(text) + if (!match) return { rest: text } + const alignment = match[1]?.toLowerCase() + if (alignment !== "left" && alignment !== "center" && alignment !== "right") return { rest: text } + // The native desktop Markdown parser keeps one-line image links that follow + // an HTML spacer as text. Strip that presentation-only spacer, and insert a + // blank line between consecutive badge links so every parser sees complete + // paragraphs. The original source remains untouched in Edit mode. + const escapeAttribute = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<") + const badgeLine = /^\s*\[!\[([^\]\r\n]*)\]\((\S+)\)\]\((\S+)\)\s*$/ + const lines = (match[2] ?? "").replace(/^\s*<br\s*\/?>\s*$/gim, "").split(/\r?\n/) + const normalized: string[] = [] + let badges: string[] = [] + const flushBadges = () => { + if (!badges.length) return + // The desktop Markdown parser does not recognize linked-image Markdown. + // Emit its finite badge form as HTML, then let the shared Markdown renderer + // run DOMPurify and the existing image resolver over it as usual. + normalized.push(`<p class="atlas-file-badges">${badges.join("\n")}</p>`) + badges = [] + } + for (const line of lines) { + const badge = badgeLine.exec(line) + if (badge) { + const [, alt = "", src = "", href = ""] = badge + badges.push( + `<a href="${escapeAttribute(href)}"><img src="${escapeAttribute(src)}" alt="${escapeAttribute(alt)}"></a>`, + ) + continue + } + flushBadges() + normalized.push(line) + } + flushBadges() + const lead = normalized.join("\n") + return { + lead: { alignment, text: lead.trim() }, + rest: text.slice(match[0].length), + } +} diff --git a/frontend/workspace/src/atlas/FileToolbar.tsx b/frontend/workspace/src/atlas/FileToolbar.tsx index 4a09df2d..e41ceebc 100644 --- a/frontend/workspace/src/atlas/FileToolbar.tsx +++ b/frontend/workspace/src/atlas/FileToolbar.tsx @@ -7,6 +7,7 @@ export interface FileToolbarProps { location?: string description: FileDescription source: boolean + sourceLabel?: string dirty: boolean saving: boolean writable?: boolean @@ -34,7 +35,7 @@ export function FileToolbar(props: FileToolbarProps): JSX.Element { ) const views = createMemo(() => controls().filter((control) => control.id === "preview" || control.id === "source")) const artifact = createMemo(() => - artifactControl({ session: props.artifact === true, busy: props.archiving === true }), + artifactControl({ session: props.artifact === true, busy: props.archiving === true, dirty: props.dirty }), ) const changes = createMemo(() => props.writable === false ? [] : controls().filter((control) => control.id === "discard" || control.id === "save"), @@ -74,20 +75,23 @@ export function FileToolbar(props: FileToolbarProps): JSX.Element { <Show when={views().length > 0}> <div class="atlas-file-modes" role="tablist" aria-label="File view"> <For each={views()}> - {(control) => ( - <button - type="button" - role="tab" - aria-label={control.label} - aria-selected={control.active === true} - class="atlas-file-mode" - classList={{ "is-active": control.active === true }} - disabled={props.disabled} - onClick={() => action(control.id)} - > - {control.label} - </button> - )} + {(control) => { + const label = () => (control.id === "source" ? (props.sourceLabel ?? control.label) : control.label) + return ( + <button + type="button" + role="tab" + aria-label={label()} + aria-selected={control.active === true} + class="atlas-file-mode" + classList={{ "is-active": control.active === true }} + disabled={props.disabled} + onClick={() => action(control.id)} + > + {label()} + </button> + ) + }} </For> </div> </Show> @@ -116,7 +120,8 @@ export function FileToolbar(props: FileToolbarProps): JSX.Element { <button type="button" class="atlas-file-action" - aria-label="Save as artifact" + aria-label={control().label} + title={control().label} disabled={props.disabled || control().disabled} onClick={() => props.onArtifact?.()} > diff --git a/frontend/workspace/src/atlas/FilesPane.test.ts b/frontend/workspace/src/atlas/FilesPane.test.ts index c812d56a..88719970 100644 --- a/frontend/workspace/src/atlas/FilesPane.test.ts +++ b/frontend/workspace/src/atlas/FilesPane.test.ts @@ -82,7 +82,7 @@ const trashed = (id: string, title: string) => ({ }, }) -// The same record in its active state — what "All artifacts" is supposed to +// The same record in its active state — what "Saved artifacts" is supposed to // list. `size` and `sourcePath` differ from the trashed fixture so a row // proves it read the artifact, not some other row's fields. const saved = (id: string, title: string) => ({ @@ -131,11 +131,33 @@ describe("files pane", () => { ) await settle() - expect(host.querySelector("[data-source-button]")?.textContent).toContain("All artifacts") + expect(host.querySelector("[data-source-button]")?.textContent).toContain("Saved artifacts") + expect(host.querySelector<HTMLInputElement>('input[type="search"]')?.placeholder).toBe("Search artifacts") expect(host.querySelector("[data-artifact-grid]")).not.toBeNull() expect(host.textContent).not.toContain("SHOULD_NOT_APPEAR.py") }) + test("keeps source and search together as the primary browser toolbar", async () => { + const host = mount(() => + subject.FilesPane({ + request: async (path) => { + if (path.startsWith("/file/artifact-store")) return listing([]) + return listing([]) + }, + }), + ) + await settle() + + const toolbar = host.querySelector(".files-browser__toolbar") + expect(toolbar).not.toBeNull() + expect(toolbar?.querySelector("[data-source-button]")).not.toBeNull() + expect(toolbar?.querySelector('input[type="search"]')).not.toBeNull() + expect(host.querySelector(".files-location")).toBeNull() + // The artifact catalog owns its own count + retention summary; the shell + // does not repeat the same context line above it. + expect(host.querySelector("[data-source-context]")).toBeNull() + }) + test("remembers the source it was left on", async () => { const request = async (path: string) => { if (path.startsWith("/file/artifact-store?state=trash")) return listing([]) @@ -175,7 +197,7 @@ describe("files pane", () => { ) await settle() - expect(host.querySelector("[data-source-button]")?.textContent).toContain("All artifacts") + expect(host.querySelector("[data-source-button]")?.textContent).toContain("Saved artifacts") // The store is empty in this fixture, so the surface is the empty state // rather than a grid container. expect(host.querySelector(".artifact-surface")).not.toBeNull() @@ -288,7 +310,7 @@ describe("files pane", () => { host.querySelector<HTMLButtonElement>("[data-source-button]")!.click() await settle() - expect(host.querySelector("[data-source-button]")?.textContent).toContain("All artifacts") + expect(host.querySelector("[data-source-button]")?.textContent).toContain("Saved artifacts") // The store is empty in this fixture, so the surface is the empty state // rather than a grid container. expect(host.querySelector(".artifact-surface")).not.toBeNull() @@ -324,8 +346,8 @@ describe("files pane", () => { expect([...host.querySelectorAll("[data-file-name]")].map((node) => node.textContent)).toEqual(["ckpt", "notes.md"]) }) - // A Volume file has no path on this machine, so a previewable one opens a tab - // backed by its bytes rather than by a path. + // A Volume file has no path on this machine, so a previewable one opens a + // focused byte-backed preview rather than pretending it is a local work tab. // A Volume listing spawns a Modal process and takes seconds. The rows still on // screen describe the folder being left, so clicking a second one appended its // name to the path the first click had already set -- asking the server for a @@ -360,6 +382,7 @@ describe("files pane", () => { expect(host.querySelector("[data-files-loading]")).not.toBeNull() expect(host.querySelector<HTMLButtonElement>('[data-file-row="beta"]')?.disabled).toBe(true) + expect(host.textContent).not.toContain("This folder is empty.") // The click that used to produce volume/alpha/beta. host.querySelector<HTMLButtonElement>('[data-file-row="beta"]')?.click() @@ -381,17 +404,24 @@ describe("files pane", () => { await settle() await enterModal(host) host.querySelector<HTMLButtonElement>('[data-file-row="weights"]')?.click() - await settle() + for (let attempt = 0; attempt < 20 && !host.querySelector('[data-file-row="notes.md"]'); attempt += 1) + await settle() - host.querySelector<HTMLButtonElement>('[data-file-row="notes.md"]')?.click() - await settle() + const notes = host.querySelector<HTMLButtonElement>('[data-file-row="notes.md"]') + expect(notes).not.toBeNull() + notes!.click() + for (let attempt = 0; attempt < 20 && !host.querySelector("[data-remote-text]"); attempt += 1) await settle() - expect(host.querySelector('[data-tab="notes.md"]')).not.toBeNull() + expect(host.querySelector('[role="tablist"]')).toBeNull() expect(host.querySelector("[data-remote-text]")?.textContent).toContain("remote bytes") expect(calls).toContain("/settings/compute/modal/volumes/weights/file?path=/notes.md") + + host.querySelector<HTMLButtonElement>('[aria-label="Close notes.md"]')?.click() + expect(host.querySelector("[data-remote-text]")).toBeNull() + expect(host.querySelector('[data-file-row="notes.md"]')).not.toBeNull() }) - test("downloads a Volume file it will not preview instead of opening an empty tab", async () => { + test("downloads a Volume file it will not preview instead of opening an empty preview", async () => { const got: string[] = [] const { request } = modal({ files: [ @@ -409,10 +439,11 @@ describe("files pane", () => { await settle() expect(got).toEqual(["model.safetensors"]) - expect(host.querySelector('[data-tab="model.safetensors"]')).toBeNull() + expect(host.querySelector("[data-remote-unsupported]")).toBeNull() + expect(host.querySelector('[role="tablist"]')).toBeNull() }) - test("renders the picker and table directly without a Browse tab", async () => { + test("renders the browser directly before any file is opened", async () => { startOn("project") const host = mount(() => subject.FilesPane({ @@ -465,6 +496,38 @@ describe("files pane", () => { expect(host.textContent).not.toContain("This folder is empty.") }) + test("shows the current folder as a breadcrumb and jumps straight to the source root", async () => { + startOn("project") + const host = mount(() => + subject.FilesPane({ + request: async (_path, _init, query) => + query?.path?.endsWith("/data") + ? listing([{ name: "nested.csv", type: "file", size: 24 }]) + : listing([{ name: "data", type: "directory" }]), + directory: DIRECTORY, + session: SESSION, + }), + ) + await settle() + + expect(host.querySelector("[data-source-context]")?.textContent).toContain("Project files") + + host.querySelector<HTMLButtonElement>('[data-file-row="data"]')?.click() + await settle() + + expect(host.querySelector("[data-source-context]")).toBeNull() + expect(host.querySelector('[data-path-crumb="0"]')?.textContent).toBe("data") + expect(host.querySelector('[data-path-crumb="0"]')?.getAttribute("aria-current")).toBe("page") + expect(host.querySelector('[data-file-row="nested.csv"]')).not.toBeNull() + + host.querySelector<HTMLButtonElement>("[data-path-root]")?.click() + await settle() + + expect(host.querySelector("[data-path-crumb]")).toBeNull() + expect(host.querySelector("[data-source-context]")?.textContent).toContain("Project files") + expect(host.querySelector('[data-file-row="data"]')).not.toBeNull() + }) + test("a failed listing degrades in place instead of throwing to the boundary", async () => { startOn("project") // The pane must not reach the app-wide ErrorBoundary. Mount it inside a real @@ -487,6 +550,94 @@ describe("files pane", () => { expect(host.querySelector("[data-boundary]")).toBeNull() expect(host.textContent).toContain("could not be read") expect(host.querySelector(".files-table")).not.toBeNull() + expect(host.querySelector('[role="alert"][data-files-error]')).not.toBeNull() + expect(host.querySelector<HTMLButtonElement>(".files-notice__retry")?.textContent).toBe("Retry") + expect(host.textContent).not.toContain("This folder is empty.") + }) + + test("retries a recoverable listing error and replaces it with the new rows", async () => { + startOn("project") + let attempts = 0 + const host = mount(() => + subject.FilesPane({ + request: async (path) => { + if (path.startsWith("/file/artifact-store")) return listing([]) + if (path === "/file") { + attempts += 1 + return attempts === 1 + ? new Response("temporarily unavailable", { status: 503 }) + : listing([{ name: "recovered.csv", type: "file", size: 12 }]) + } + return listing([]) + }, + }), + ) + await settle() + + expect(host.querySelector('[role="alert"][data-files-error]')).not.toBeNull() + host.querySelector<HTMLButtonElement>(".files-notice__retry")?.click() + await settle() + + expect(attempts).toBe(2) + expect(host.querySelector("[data-files-error]")).toBeNull() + expect(host.querySelector('[data-file-row="recovered.csv"]')).not.toBeNull() + }) + + test("names a filtered-empty folder without claiming the folder itself is empty", async () => { + startOn("project") + const host = mount(() => + subject.FilesPane({ + request: async (path) => + path.startsWith("/file/artifact-store") + ? listing([]) + : listing([{ name: "analysis.ipynb", type: "file", size: 12 }]), + }), + ) + await settle() + + const search = host.querySelector<HTMLInputElement>('input[type="search"]')! + expect(search.placeholder).toBe("Filter this folder") + search.value = "missing" + search.dispatchEvent(new Event("input", { bubbles: true })) + await settle() + + expect(host.textContent).toContain("No matching files") + expect(host.textContent).not.toContain("This folder is empty.") + expect(host.querySelector("[data-search-clear]")).not.toBeNull() + + host.querySelector<HTMLButtonElement>("[data-search-clear]")?.click() + await settle() + + expect(host.querySelector('[data-file-row="analysis.ipynb"]')).not.toBeNull() + expect(host.querySelector("[data-search-clear]")).toBeNull() + }) + + test("surfaces and retries the selected artifact source independently", async () => { + let activeAttempts = 0 + const host = mount(() => + subject.FilesPane({ + request: async (path) => { + if (path.includes("state=trash")) return listing([]) + if (path.includes("state=active")) { + activeAttempts += 1 + return activeAttempts === 1 + ? new Response("artifact service warming up", { status: 503 }) + : listing([saved("art_9", "recovered.ipynb")]) + } + return listing([]) + }, + }), + ) + await settle() + + expect(host.querySelector("[data-files-error]")?.textContent).toContain("Saved artifacts could not be loaded") + expect(host.textContent).not.toContain("No artifacts saved yet.") + host.querySelector<HTMLButtonElement>(".files-notice__retry")?.click() + await settle() + + expect(activeAttempts).toBe(2) + expect(host.querySelector("[data-files-error]")).toBeNull() + expect(host.querySelector('[data-card-open][aria-label^="Open recovered.ipynb"]')).not.toBeNull() }) test("reaches trashed artifacts from the source menu and restores one", async () => { @@ -524,7 +675,7 @@ describe("files pane", () => { expect(host.querySelector('[data-trash-row="art_1"]')).toBeNull() }) - test("lists saved artifacts under All artifacts rather than reporting an empty folder", async () => { + test("lists saved artifacts under Saved artifacts rather than reporting an empty folder", async () => { // The artifacts source short-circuited to [] and fell through to the file // table's "This folder is empty." — so a project with artifacts in it said // it had none. The active half of the snapshot was already being loaded @@ -547,7 +698,7 @@ describe("files pane", () => { await settle() const names = [...host.querySelectorAll("[data-card-open]")].map((node) => node.getAttribute("aria-label")) - expect(names).toEqual(["Open peak_fit.ipynb"]) + expect(names).toEqual(["Open peak_fit.ipynb, version 2 of 2"]) expect(host.querySelector("[data-artifact-grid]")).not.toBeNull() expect(host.textContent).not.toContain("This folder is empty.") expect(host.textContent).not.toContain("SHOULD_NOT_APPEAR.py") @@ -588,7 +739,7 @@ describe("files pane", () => { host.querySelector<HTMLButtonElement>('[data-source-item="artifacts"]')?.click() await settle() - expect(host.textContent).toContain("No artifacts saved yet.") + expect(host.textContent).toContain("No saved artifacts yet") expect(host.textContent).not.toContain("This folder is empty.") }) @@ -607,11 +758,8 @@ describe("files pane", () => { return listing([]) }, onOpenArtifact: (artifact) => opened.push(artifact.id), - view: (file) => { + onOpenFile: (file) => { viewed.push(file.path) - const node = document.createElement("p") - node.dataset.stubView = file.path - return node }, }), ) @@ -848,17 +996,14 @@ describe("files pane", () => { expect(host.querySelector(".files-notice")?.textContent).toContain("has not started yet") }) - test("opening a file swaps the browser for the file itself and closing swaps it back", async () => { + test("delegates local files to the inspector's single work-tab owner", async () => { startOn("project") + const opened: PaneFile[] = [] const host = mount(() => subject.FilesPane({ directory: DIRECTORY, request: async () => listing([{ name: "train_lr.py", type: "file", size: 10, path: "src/train_lr.py" }]), - view: (file) => { - const node = document.createElement("p") - node.dataset.stubView = file.path - return node - }, + onOpenFile: (file) => opened.push(file), }), ) await settle() @@ -867,21 +1012,47 @@ describe("files pane", () => { host.querySelector<HTMLButtonElement>('[data-file-row="train_lr.py"]')?.click() - // The tab is not just added — it becomes the pane's content. - expect(host.querySelector('[data-tab="train_lr.py"]')?.getAttribute("aria-selected")).toBe("true") - expect(host.querySelector("[data-stub-view]")?.getAttribute("data-stub-view")).toBe("src/train_lr.py") - expect(host.querySelector(".files-table")).toBeNull() - expect(host.querySelector("[data-source-button]")).toBeNull() - - host.querySelector<HTMLElement>('[data-tab-close="train_lr.py"]')?.click() - - expect(host.querySelector("[data-stub-view]")).toBeNull() - expect(host.querySelector('[data-tab="train_lr.py"]')).toBeNull() + expect(opened).toEqual([{ name: "train_lr.py", path: "src/train_lr.py", source: "proj", readonly: undefined }]) + // FilesPane never creates a competing inner strip. In production the + // uiStore callback activates RightPane's persisted WorkTabStrip. expect(host.querySelector('[role="tablist"]')).toBeNull() expect(host.querySelector(".files-table")).not.toBeNull() }) - test("a tab keeps the source it was opened from, not whichever one is selected later", async () => { + test("keeps same-named files distinct by their full paths", async () => { + startOn("project") + const opened: PaneFile[] = [] + const host = mount(() => + subject.FilesPane({ + directory: DIRECTORY, + session: SESSION, + request: async (path, _init, query) => { + if (path.startsWith("/file/artifact-store")) return listing([]) + if (query?.path?.endsWith("/src") || query?.path?.endsWith("/tests")) + return listing([{ name: "index.ts", type: "file", size: 12 }]) + return listing([ + { name: "src", type: "directory" }, + { name: "tests", type: "directory" }, + ]) + }, + onOpenFile: (file) => opened.push(file), + }), + ) + await settle() + + host.querySelector<HTMLButtonElement>('[data-file-row="src"]')?.click() + await settle() + host.querySelector<HTMLButtonElement>('[data-file-row="index.ts"]')?.click() + host.querySelector<HTMLButtonElement>("[data-path-root]")?.click() + await settle() + host.querySelector<HTMLButtonElement>('[data-file-row="tests"]')?.click() + await settle() + host.querySelector<HTMLButtonElement>('[data-file-row="index.ts"]')?.click() + + expect(opened.map((file) => file.path)).toEqual([`${DIRECTORY}/src/index.ts`, `${DIRECTORY}/tests/index.ts`]) + }) + + test("an opened file keeps the source it came from when the browser moves", async () => { // writable/subtitle used to read the picker's *current* source, so a file // opened from a read-only grant became editable the moment the picker moved // on — the read/write boundary followed the menu instead of the file. @@ -900,9 +1071,8 @@ describe("files pane", () => { { name: "inputs.csv", type: "file", size: 4, path: "/home/keertan/data/pdebench/inputs.csv" }, ]) }, - view: (file) => { + onOpenFile: (file) => { seen.push(file as PaneFile) - return document.createElement("p") }, }), ) @@ -917,13 +1087,11 @@ describe("files pane", () => { expect(seen.at(-1)).toMatchObject({ name: "inputs.csv", source: "pdebench", readonly: true }) - // Back to the browser, move the picker to the project, then return to the tab. - host.querySelector<HTMLButtonElement>('[data-tab="files"]')?.click() + // Moving the browser after opening does not mutate the location already + // handed to the owning work tab. host.querySelector<HTMLButtonElement>("[data-source-button]")?.click() host.querySelector<HTMLButtonElement>('[data-source-item="project"]')?.click() await settle() - host.querySelector<HTMLButtonElement>('[data-tab="inputs.csv"]')?.click() - await settle() expect(seen.at(-1)).toMatchObject({ source: "pdebench", readonly: true }) }) @@ -1004,15 +1172,12 @@ describe("files pane", () => { expect(source).toContain("value={connect.path}") }) - test("mounts the real FileView for the active tab when nothing overrides it", () => { - // The `view` seam above can only prove the switch, not what production - // renders through it. This guards the default the seam falls back to. + test("routes local files to the persisted inspector strip instead of mounting a second viewer", () => { const source = readFileSync(fileURLToPath(new URL("./FilesPane.tsx", import.meta.url)), "utf8") - expect(source).toContain('import { FileView } from "@/atlas/FilePreview"') - expect(source).toContain("props.view?.(file) ?? (") - expect(source).toContain("<FileView") - expect(source).toContain("path={file.path}") - expect(source).toContain("onClose={() => closeTab(file.name)}") + expect(source).toContain('import { uiStore } from "@/atlas/store/ui"') + expect(source).toContain("if (props.onOpenFile) return props.onOpenFile(file)") + expect(source).toContain("uiStore.openFile(projectRoot(), file.path)") + expect(source).not.toContain('import { FileView } from "@/atlas/FilePreview"') }) }) diff --git a/frontend/workspace/src/atlas/FilesPane.tsx b/frontend/workspace/src/atlas/FilesPane.tsx index 64c58a90..d6dc2af9 100644 --- a/frontend/workspace/src/atlas/FilesPane.tsx +++ b/frontend/workspace/src/atlas/FilesPane.tsx @@ -18,7 +18,6 @@ import { useSync } from "@/context/sync" import { SourceMenu } from "@/atlas/files/SourceMenu" import { ArtifactGrid } from "@/atlas/files/ArtifactGrid" import { FileTable, type FileRow } from "@/atlas/files/FileTable" -import { FileTabs } from "@/atlas/files/FileTabs" import { TrashList } from "@/atlas/files/TrashList" import { buildSources, type PaneSource } from "@/atlas/files/sources" import { readSource, writeSource } from "@/atlas/files/last-source" @@ -27,8 +26,8 @@ import { remotePreview } from "@/atlas/files/remote-preview" import { createArtifactsResource, restoreStoredArtifact } from "@/artifacts/resource" import type { StoredArtifact } from "@/artifacts/store" import { uiStore } from "@/atlas/store/ui" -import { FileView } from "@/atlas/FilePreview" import { FolderPicker } from "@/atlas/FolderPicker" +import { IconChevronRight, IconFolder, IconSearch, IconX } from "@/atlas/shared/Icon" import { connectedFilesystemGrants, parseFilesystemSnapshot, @@ -42,23 +41,17 @@ import "@/atlas/files/FilesPane.css" export type Transport = (path: string, init?: RequestInit, query?: Record<string, string>) => Promise<Response> -/** An open tab: the name the strip shows, and the handle FileView reads. */ +/** The durable location handed to the inspector's single work-tab owner. */ export interface PaneFile { name: string path: string /** - * The source the file was opened from. A tab outlives the picker's current - * selection, so it carries its own provenance rather than reading whichever - * source happens to be selected when it is next shown — otherwise a file - * opened from a read-only grant becomes editable the moment the picker moves. + * Provenance for integration callbacks. The work-tab owner receives the + * stable path, so it never reinterprets a file through the browser's later + * source selection. */ source: string readonly?: boolean - /** - * Set when the tab is a file inside a Modal Volume. Such a file has no path on - * this machine, so it is previewed from its bytes rather than read from disk. - */ - remote?: RemoteFile } async function json(response: Response): Promise<unknown> { @@ -185,7 +178,8 @@ export function FilesPane( request?: Transport session?: string directory?: string - view?: (file: PaneFile) => JSX.Element + /** Test/integration seam. Production delegates to uiStore.openFile. */ + onOpenFile?: (file: PaneFile) => void /** * Builds an absolute URL for an artifact's bytes. `sdk.request.url` supplies * it in production; a standalone mount has no SDK, and `transport` returns a @@ -310,11 +304,12 @@ export function FilesPane( const [path, setPath] = createSignal<string[]>([]) const [filter, setFilter] = createSignal("") const [error, setError] = createSignal("") - const [tabs, setTabs] = createSignal<PaneFile[]>([]) - // Undefined is the browser itself. A sentinel string would be a filename a - // real file can carry, and "files" is one — the browser would then be - // unreachable behind a tab it cannot tell apart from itself. - const [active, setActive] = createSignal<string>() + const [listingError, setListingError] = createSignal("") + // Local/project/connected files are owned by RightPane's persisted work-tab + // strip. Modal Volume bytes cannot be represented by a local ContextFile, so + // they use one focused preview with an explicit return control instead of a + // second, competing tab system. + const [remoteOpen, setRemoteOpen] = createSignal<RemoteFile>() const [busy, setBusy] = createSignal(false) const [connect, setConnect] = createStore({ open: false, @@ -338,7 +333,7 @@ export function FilesPane( const key = createMemo(() => [where(), sessionID(), current().kind, current().id] as const, undefined, { equals: (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3], }) - const [entries] = createResource(key, ([target, session, kind, id]) => { + const [entries, { refetch: refetchEntries }] = createResource(key, ([target, session, kind, id]) => { // The artifacts and trash pseudo-sources always have root "" — they are // backed by the artifact store, not the filesystem, and the server // falls back an empty path to the project root (File.list(dir || root)), @@ -351,6 +346,7 @@ export function FilesPane( // describes anything on screen — leaving it up puts "this folder could // not be read" over a perfectly good trash list. setError("") + setListingError("") return Promise.resolve([] as FileRow[]) } // A Volume is not on this machine: it lists over Modal's API, and its @@ -364,7 +360,7 @@ export function FilesPane( return transport("/settings/compute/modal/volumes") .then(json) .then((value) => { - setError("") + setListingError("") if (!Array.isArray(value)) return [] as FileRow[] // Volumes are folders here: entering one lists it. return (value as Array<{ name: string }>).map((item) => ({ @@ -373,7 +369,7 @@ export function FilesPane( })) }) .catch((value) => { - setError(`Modal Volumes could not be listed. ${concise(value)}`) + setListingError(`Modal Volumes could not be listed. ${concise(value)}`) return [] as FileRow[] }) } @@ -382,7 +378,7 @@ export function FilesPane( }) .then(json) .then((value) => { - setError("") + setListingError("") if (!Array.isArray(value)) return [] as FileRow[] return (value as Array<{ path: string; type: string; size: number }>).map((entry) => ({ name: entry.path.split("/").filter(Boolean).at(-1) ?? entry.path, @@ -392,7 +388,7 @@ export function FilesPane( })) }) .catch((value) => { - setError(`${volume} could not be read. ${concise(value)}`) + setListingError(`${volume} could not be read. ${concise(value)}`) return [] as FileRow[] }) } @@ -401,7 +397,7 @@ export function FilesPane( return transport("/file", undefined, query) .then(json) .then((value) => { - setError("") + setListingError("") // GET /file returns a bare FileNode[] (backend/cli/src/server/routes/file.ts:158-182, // FileListResponses in tooling/sdk/js/src/v2/gen/types.gen.ts:7889). The {data} // wrapper only exists on the generated client's RequestResult, never on the body. @@ -410,11 +406,39 @@ export function FilesPane( return Array.isArray(data) ? (data as FileRow[]) : [] }) .catch(() => { - setError("This folder could not be read. The last listing may be out of date.") + setListingError("This folder could not be read. The last listing may be out of date.") return [] as FileRow[] }) }) + const sourceLoading = createMemo(() => { + const kind = current().kind + return kind === "artifacts" || kind === "trash" ? artifacts.loading : entries.loading + }) + + const sourceError = createMemo(() => { + const kind = current().kind + if (kind === "artifacts") { + const message = artifacts.latest?.errors.active + return message ? `Saved artifacts could not be loaded. ${message}` : "" + } + if (kind === "trash") { + const message = artifacts.latest?.errors.trash + return message ? `Trash could not be loaded. ${message}` : "" + } + return listingError() + }) + + const retrySource = () => { + const kind = current().kind + if (kind === "artifacts" || kind === "trash") { + void refetchArtifacts() + return + } + setListingError("") + void refetchEntries() + } + const rows = createMemo(() => { const query = filter().trim().toLowerCase() const list = entries.latest ?? [] @@ -436,6 +460,54 @@ export function FilesPane( return query ? list.filter((item) => item.title.toLowerCase().includes(query)) : list }) + const filterCopy = createMemo(() => { + if (current().kind === "artifacts") return "Search artifacts" + if (current().kind === "trash") return "Search trash" + return "Filter this folder" + }) + + /** + * The source picker answers "where?"; this line answers "what kind of place + * is this?" without turning the compact toolbar into a storage manual. Keep + * every claim inside the contracts the pane can actually observe. + */ + const sourceContext = createMemo(() => { + const source = current() + // The artifact catalog and Trash already explain their retention model in + // their own first content row. Repeating it here would add a third label + // for the same concept directly above that row. + if (source.kind === "artifacts" || source.kind === "trash") return + if (source.kind === "session") + return { + label: "Session workspace", + copy: "Scratch files for this session.", + badge: "Scratch", + } + if (source.kind === "modal") + return { + label: "Remote files", + copy: "Browse and download from configured Modal Volumes.", + badge: "Read only", + } + if (source.kind === "connected") + return source.readonly + ? { + label: "Connected folder", + copy: "Files can be inspected without changing the folder.", + badge: "Read only", + } + : { + label: "Connected folder", + copy: "Approved file tools can write here; runtimes do not receive a writable mount.", + badge: "Tool write", + } + return { + label: "Project files", + copy: "Working files in this project's folder.", + badge: "This computer", + } + }) + // Session titles label the grid's groups. They live in the sync store, which // a standalone mount has no access to, so the map is simply empty there and // groupBySession falls back to abbreviated ids. @@ -549,63 +621,35 @@ export function FilesPane( .finally(() => setBusy(false)) } - // Tabs are keyed by name because that is what the strip shows. Re-opening a - // name from a different folder re-points the existing tab rather than - // stacking a second, indistinguishable one. const open = (row: FileRow) => { const from = current() + const target = row.path ?? [where(), row.name].filter(Boolean).join("/") const file: PaneFile = { name: row.name, - path: row.path ?? [where(), row.name].filter(Boolean).join("/"), + path: target, source: from.name, readonly: from.readonly, } - const known = tabs().some((tab) => tab.name === file.name) - setTabs(known ? tabs().map((tab) => (tab.name === file.name ? file : tab)) : [...tabs(), file]) - setActive(file.name) + if (props.onOpenFile) return props.onOpenFile(file) + uiStore.openFile(projectRoot(), file.path) } - const openRemote = (remote: RemoteFile) => { - const file: PaneFile = { name: remote.name, path: remote.path, source: current().name, readonly: true, remote } - const known = tabs().some((tab) => tab.name === file.name) - setTabs(known ? tabs().map((tab) => (tab.name === file.name ? file : tab)) : [...tabs(), file]) - setActive(file.name) - } - - const closeTab = (name: string) => { - setTabs(tabs().filter((tab) => tab.name !== name)) - if (active() === name) setActive(undefined) - } - - const move = (name: string, to: number) => { - const items = [...tabs()] - const index = items.findIndex((tab) => tab.name === name) - if (index === -1) return - const target = Math.max(0, Math.min(to, items.length - 1)) - if (target === index) return - items.splice(target, 0, items.splice(index, 1)[0]) - setTabs(items) - } - - const selected = createMemo(() => tabs().find((tab) => tab.name === active())) + const openRemote = (remote: RemoteFile) => setRemoteOpen(remote) // The picker walks the real filesystem and hands back an absolute path. It // needs the dialog host, so outside a provider the typed path stays the // only route in — which is also what keeps this form testable. const browse = () => { - dialog?.show( - () => ( - <FolderPicker - kind="folder" - title="Connect a folder" - onSelect={(result) => { - const picked = Array.isArray(result) ? result[0] : result - if (picked) setConnect("path", picked) - }} - /> - ), - { lite: true }, - ) + dialog?.show(() => ( + <FolderPicker + kind="folder" + title="Connect a folder" + onSelect={(result) => { + const picked = Array.isArray(result) ? result[0] : result + if (picked) setConnect("path", picked) + }} + /> + )) } const submit = (event: SubmitEvent) => { @@ -678,29 +722,134 @@ export function FilesPane( } const browser = () => ( - <> - <div class="files-source-row"> - <SourceMenu - sources={sources()} - active={current()} - onOpen={() => setOpened(opened() + 1)} - onPick={(next) => { - choose(next.id) - setPath([]) - setFilter("") - // The notice describes the source being left, not the one arriving. - setError("") - }} - onRevoke={revoke} - onAdd={() => setConnect({ open: true, path: "", access: "read", scope: "project" })} - /> - </div> + <div class="files-browser" data-files-browser data-source-kind={current().kind}> + <header class="files-browser__header"> + <div class="files-browser__toolbar"> + <SourceMenu + sources={sources()} + active={current()} + onOpen={() => setOpened(opened() + 1)} + onPick={(next) => { + choose(next.id) + setPath([]) + setFilter("") + // The notice describes the source being left, not the one arriving. + setError("") + setListingError("") + }} + onRevoke={revoke} + onAdd={() => setConnect({ open: true, path: "", access: "read", scope: "project" })} + /> + + <div class="files-search" role="search"> + <span class="files-search__icon" aria-hidden="true"> + <IconSearch size={14} strokeWidth={1.5} /> + </span> + <input + class="files-search__input" + type="search" + value={filter()} + placeholder={filterCopy()} + aria-label={filterCopy()} + onInput={(event) => setFilter(event.currentTarget.value)} + /> + <Show when={filter()}> + <button + type="button" + class="files-search__clear" + data-search-clear + aria-label="Clear file search" + onClick={() => setFilter("")} + > + <IconX size={12} strokeWidth={1.6} /> + </button> + </Show> + </div> + </div> + + <Show + when={path().length > 0} + fallback={ + <Show when={sourceContext()} keyed> + {(context) => ( + <div + class="files-source-context" + data-source-context + data-source-kind={current().kind} + aria-label={`${context.label}. ${context.copy}`} + > + <span class="files-source-context__label">{context.label}</span> + <span class="files-source-context__divider" aria-hidden="true" /> + <span class="files-source-context__copy">{context.copy}</span> + <span class="files-source-context__badge">{context.badge}</span> + </div> + )} + </Show> + } + > + <nav class="files-path" aria-label="Current folder"> + <button + type="button" + class="files-path__root" + data-path-root + aria-label={`Open the root of ${current().name}`} + title={`Open the root of ${current().name}`} + onClick={() => { + setPath([]) + setFilter("") + }} + > + <IconFolder size={14} strokeWidth={1.45} /> + </button> + <For each={path()}> + {(part, index) => { + const last = () => index() === path().length - 1 + return ( + <> + <span class="files-path__separator" aria-hidden="true"> + <IconChevronRight size={12} strokeWidth={1.5} /> + </span> + <button + type="button" + class="files-path__crumb" + data-path-crumb={index()} + aria-current={last() ? "page" : undefined} + disabled={last()} + title={part} + onClick={() => { + setPath(path().slice(0, index() + 1)) + setFilter("") + }} + > + {part} + </button> + </> + ) + }} + </For> + </nav> + </Show> + </header> <Show when={connect.open}> <form class="files-connect" aria-label="Connect a folder" onSubmit={submit}> + <div class="files-connect__heading"> + <span> + <strong>Connect a folder</strong> + <small>Add another location to this session.</small> + </span> + <button + type="button" + class="files-connect__dismiss" + aria-label="Close folder connection form" + onClick={() => setConnect("open", false)} + > + <IconX size={13} strokeWidth={1.6} /> + </button> + </div> <div class="files-connect__row"> <input - class="files-search" + class="files-connect__path" value={connect.path} aria-label="Folder path" placeholder="/home/you/data" @@ -755,22 +904,11 @@ export function FilesPane( </form> </Show> - <div class="files-search-row"> - <input - class="files-search" - type="search" - value={filter()} - placeholder={`Search ${current().name}`} - aria-label={`Search ${current().name}`} - onInput={(event) => setFilter(event.currentTarget.value)} - /> - </div> - {/* A Volume listing spawns a Modal process and takes seconds. Without this the pane looks frozen, and the rows still on screen describe the folder being left -- which is how clicking a second one asked for a folder inside a folder that was never opened. */} - <Show when={entries.loading}> + <Show when={sourceLoading()}> <div class="files-loading" role="status" data-files-loading> <span class="files-loading__spark" aria-hidden="true" /> Loading {current().name}… @@ -778,17 +916,33 @@ export function FilesPane( </Show> <Show when={error()}> - <div class="files-notice" role="status"> + <div class="files-notice" role="alert"> {error()} </div> </Show> + <Show when={!sourceLoading() && sourceError()}> + <div class="files-notice files-notice--error" role="alert" data-files-error> + <span>{sourceError()}</span> + <button type="button" class="files-notice__retry" onClick={retrySource}> + Retry + </button> + </div> + </Show> + {/* One surface per source kind. A Switch says that outright; the nested Show whose fallback re-tested the same condition left a reader to derive the exclusivity. */} <Switch> <Match when={current().kind === "trash"}> - <TrashList rows={trash()} busy={busy()} onRestore={restore} /> + <TrashList + rows={trash()} + busy={busy()} + filtered={Boolean(filter().trim())} + loading={sourceLoading()} + unavailable={Boolean(sourceError())} + onRestore={restore} + /> </Match> <Match when={current().kind === "artifacts"}> @@ -797,6 +951,8 @@ export function FilesPane( titles={titles()} currentSession={sessionID()} filtered={Boolean(filter().trim())} + loading={sourceLoading()} + unavailable={Boolean(sourceError())} url={artifactUrl} read={readArtifact} onOpen={openArtifact} @@ -809,7 +965,10 @@ export function FilesPane( <FileTable rows={rows()} depth={path().length} - busy={entries.loading} + busy={sourceLoading()} + filtered={Boolean(filter().trim())} + loading={sourceLoading()} + unavailable={Boolean(sourceError())} onUp={() => { setPath(path().slice(0, -1)) // Symmetric with descending: a query typed for the folder being @@ -837,45 +996,20 @@ export function FilesPane( /> </Match> </Switch> - </> + </div> ) return ( <section class="files-pane" aria-label="Files"> - <FileTabs - open={tabs().map((tab) => tab.name)} - active={active()} - onSelect={(id) => setActive(id)} - onClose={closeTab} - onReorder={move} - /> - - <Show when={selected()} keyed fallback={browser()}> - {(file) => - file.remote ? ( - <RemoteFileView - file={file.remote} - read={remoteBytes} - onDownload={(remote) => void downloadRemote({ name: remote.name, type: "file", path: remote.path })} - onClose={() => closeTab(file.name)} - /> - ) : ( - // FileView reads the SDK, sync and router contexts, so a standalone - // mount cannot render it; `view` lets that harness substitute a stub - // exactly as `request` substitutes the transport. Production never - // passes it and always gets the real viewer. - (props.view?.(file) ?? ( - <FileView - directory={projectRoot()} - path={file.path} - subtitle={file.source} - active - writable={file.readonly ? false : undefined} - onClose={() => closeTab(file.name)} - /> - )) - ) - } + <Show when={remoteOpen()} keyed fallback={browser()}> + {(file) => ( + <RemoteFileView + file={file} + read={remoteBytes} + onDownload={(remote) => void downloadRemote({ name: remote.name, type: "file", path: remote.path })} + onClose={() => setRemoteOpen()} + /> + )} </Show> </section> ) diff --git a/frontend/workspace/src/atlas/FolderPicker.css b/frontend/workspace/src/atlas/FolderPicker.css new file mode 100644 index 00000000..b5db1520 --- /dev/null +++ b/frontend/workspace/src/atlas/FolderPicker.css @@ -0,0 +1,692 @@ +/* The folder picker is a filesystem utility, not a second application shell. + Keep its hierarchy quiet and scope every override to the picker dialog so + shared Dialog, Button, and input geometry remains unchanged elsewhere. */ +.folder-picker-dialog { + min-height: 0; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: var(--atlas-radius-md); + background: var(--surface-raised-stronger-non-alpha); + box-shadow: var(--atlas-shadow-float); + color: var(--text-base); +} + +.folder-picker-dialog [data-slot="dialog-header"] { + min-height: 58px; + padding: 16px 16px 12px 20px; +} + +.folder-picker-dialog [data-slot="dialog-title"] { + font-size: 17px; + font-weight: var(--font-weight-medium); + line-height: 24px; + letter-spacing: -0.015em; +} + +.folder-picker-dialog [data-slot="dialog-body"] { + min-height: 0; + height: 100%; +} + +.folder-picker { + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: 188px minmax(0, 1fr); + overflow: hidden; + border-top: 1px solid var(--border-weak-base); +} + +.folder-picker__sidebar { + min-width: 0; + display: flex; + flex-direction: column; + gap: 22px; + overflow-x: hidden; + overflow-y: auto; + padding: 16px 12px; + border-right: 1px solid var(--border-weak-base); + background: var(--surface-raised-stronger-non-alpha); +} + +.folder-picker__sidebar-group, +.folder-picker__sidebar-list { + min-width: 0; + display: flex; + flex-direction: column; +} + +.folder-picker__sidebar-group { + gap: 3px; +} + +.folder-picker__sidebar-list { + gap: 2px; +} + +.folder-picker__section-label { + padding: 0 8px 6px; + color: var(--text-weak); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 17px; +} + +.folder-picker__sidebar-row { + min-width: 0; + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 8px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--text-weak); + font: inherit; + text-align: left; + cursor: pointer; + transition: + background-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} + +.folder-picker__sidebar-row:hover, +.folder-picker__sidebar-row:focus-visible { + background: var(--surface-raised-base-hover); + color: var(--text-strong); +} + +.folder-picker__sidebar-row[data-active="true"] { + background: var(--surface-raised-base-active); + color: var(--text-strong); +} + +.folder-picker__sidebar-row:focus-visible, +.folder-picker__icon-button:focus-visible, +.folder-picker__breadcrumb:focus-visible, +.folder-picker__row-open:focus-visible, +.folder-picker__choose:focus-visible, +.folder-picker__retry:focus-visible, +.folder-picker__go:focus-visible { + outline: 1px solid var(--focus-lit-ring); + outline-offset: -2px; +} + +.folder-picker__sidebar-copy { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; +} + +.folder-picker__sidebar-label, +.folder-picker__sidebar-sublabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-picker__sidebar-label { + color: currentColor; + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 18px; +} + +.folder-picker__sidebar-row[data-active="true"] .folder-picker__sidebar-label { + font-weight: var(--font-weight-medium); +} + +.folder-picker__sidebar-sublabel { + color: var(--text-weaker); + font-family: var(--font-family-mono); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 16px; +} + +.folder-picker__main { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px 18px; + background: var(--surface-raised-stronger-non-alpha); +} + +.folder-picker__location { + min-width: 0; + min-height: 40px; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border: 1px solid var(--border-weak-base); + border-radius: var(--atlas-radius-sm); + background: var(--surface-raised-base); +} + +.folder-picker__navigation { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; +} + +.folder-picker__icon-button { + width: 32px; + height: 32px; + display: inline-flex; + flex: 0 0 32px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--text-weak); + cursor: pointer; +} + +.folder-picker__icon-button:hover:not(:disabled) { + background: var(--surface-raised-base-hover); + color: var(--text-strong); +} + +.folder-picker__icon-button:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.folder-picker__location-divider { + width: 1px; + height: 18px; + flex: 0 0 1px; + background: var(--border-weak-base); +} + +.folder-picker__breadcrumbs { + min-width: 0; + display: flex; + flex: 1; + align-items: center; + gap: 1px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.folder-picker__breadcrumbs::-webkit-scrollbar { + display: none; +} + +.folder-picker__breadcrumb { + min-width: 0; + max-width: 180px; + min-height: 28px; + overflow: hidden; + flex: 0 1 auto; + padding: 3px 6px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: 12.5px; + font-weight: var(--font-weight-regular); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.folder-picker__breadcrumb:hover { + background: var(--surface-raised-base-hover); + color: var(--text-strong); +} + +.folder-picker__breadcrumb[data-current="true"] { + color: var(--text-strong); + font-weight: var(--font-weight-medium); +} + +.folder-picker__breadcrumb-separator { + width: 14px; + height: 18px; + display: inline-flex; + flex: 0 0 14px; + align-items: center; + justify-content: center; + color: var(--text-weaker); +} + +.folder-picker__breadcrumb-separator [data-slot="icon-svg"] { + width: 12px; + height: 12px; +} + +.folder-picker__refresh { + margin-left: auto; +} + +.folder-picker__tools { + min-width: 0; + display: grid; + grid-template-columns: minmax(180px, 0.85fr) minmax(270px, 1.15fr); + gap: 10px; +} + +.folder-picker__field { + min-width: 0; + min-height: 40px; + display: flex; + align-items: center; + gap: 7px; + padding: 3px 6px 3px 10px; + border: 1px solid var(--border-weak-base); + border-radius: var(--atlas-radius-sm); + background: var(--surface-raised-base); + color: var(--text-weak); + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.folder-picker__field:focus-within { + border-color: var(--focus-lit-ring); + box-shadow: var(--focus-lit); +} + +.folder-picker__field input { + min-width: 0; + min-height: 30px; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 18px; +} + +.folder-picker__field input::placeholder { + color: var(--text-weaker); +} + +.folder-picker__path-field input { + font-family: var(--font-family-mono); + font-size: 12px; +} + +.folder-picker__field-label, +.folder-picker__count { + flex: 0 0 auto; + color: var(--text-weaker); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 18px; + white-space: nowrap; +} + +.folder-picker__count { + padding-right: 3px; + font-weight: var(--font-weight-regular); +} + +.folder-picker__go, +.folder-picker__retry, +.folder-picker__choose { + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--border-weak-base); + border-radius: var(--atlas-radius-xs); + background: var(--surface-raised-base); + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 18px; + cursor: pointer; +} + +.folder-picker__go:hover:not(:disabled), +.folder-picker__retry:hover, +.folder-picker__choose:hover { + background: var(--surface-raised-base-hover); + color: var(--text-strong); +} + +.folder-picker__go:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.folder-picker__list { + position: relative; + min-height: 0; + overflow-y: auto; + flex: 1; + border: 1px solid var(--border-weak-base); + border-radius: var(--atlas-radius-sm); + background: transparent; + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.folder-picker__list--loading { + opacity: 0.64; +} + +.folder-picker__loading { + position: sticky; + z-index: 2; + top: 0; + height: 2px; + overflow: hidden; + pointer-events: none; +} + +.folder-picker__loading > span { + width: 32%; + height: 100%; + display: block; + background: var(--text-interactive-base); + animation: folder-picker-loading 1.1s var(--ease-standard) infinite; +} + +@keyframes folder-picker-loading { + from { + transform: translateX(-110%); + } + to { + transform: translateX(320%); + } +} + +.folder-picker__row { + min-width: 0; + min-height: 46px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px 4px 4px; + border-bottom: 1px solid var(--border-weak-base); +} + +.folder-picker__row:last-child { + border-bottom: 0; +} + +.folder-picker__row:hover, +.folder-picker__row:focus-within { + background: var(--surface-raised-base-hover); +} + +.folder-picker__row-open { + min-width: 0; + min-height: 38px; + display: flex; + flex: 1; + align-items: center; + gap: 9px; + padding: 7px 8px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--text-weak); + font: inherit; + text-align: left; + cursor: pointer; +} + +.folder-picker__row-name { + min-width: 0; + overflow: hidden; + flex: 1; + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-picker__row-chevron { + width: 18px; + height: 18px; + display: inline-flex; + flex: 0 0 18px; + align-items: center; + justify-content: center; + color: var(--text-weaker); + opacity: 0.72; +} + +.folder-picker__pick-label { + color: var(--text-weaker); + font-size: 12px; + line-height: 18px; +} + +.folder-picker__choose { + flex: 0 0 auto; + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.folder-picker__row:hover .folder-picker__choose, +.folder-picker__row:focus-within .folder-picker__choose, +.folder-picker__choose:focus-visible { + opacity: 1; +} + +.folder-picker__empty { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + padding: 28px 24px; + color: var(--text-weak); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 19px; + text-align: center; +} + +.folder-picker__empty strong { + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-medium); +} + +.folder-picker__empty p { + max-width: 410px; + margin: 0; + color: var(--text-weak); +} + +.folder-picker__empty code { + font-family: var(--font-family-mono); + font-size: 12px; +} + +.folder-picker__empty--error > [data-component="icon"] { + color: var(--text-danger, var(--icon-critical-base)); +} + +.folder-picker__footer { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + padding-top: 3px; +} + +.folder-picker__current-path { + min-width: 0; + display: flex; + flex: 1; + align-items: center; + gap: 7px; + overflow: hidden; + color: var(--text-weak); + font-family: var(--font-family-mono); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-picker__current-path > [data-component="icon"] { + flex: 0 0 auto; +} + +.folder-picker__footer-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +.folder-picker__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 720px) { + .folder-picker { + grid-template-columns: 148px minmax(0, 1fr); + } + + .folder-picker__sidebar { + padding-inline: 8px; + } + + .folder-picker__main { + padding-inline: 12px; + } + + .folder-picker__tools { + grid-template-columns: minmax(150px, 0.72fr) minmax(230px, 1.28fr); + } +} + +@media (max-width: 620px) { + .folder-picker { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + } + + .folder-picker__sidebar { + flex-direction: row; + gap: 16px; + overflow-x: auto; + overflow-y: hidden; + padding: 8px 12px; + border-right: 0; + border-bottom: 1px solid var(--border-weak-base); + } + + .folder-picker__sidebar-group { + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 6px; + } + + .folder-picker__sidebar-list { + flex-direction: row; + } + + .folder-picker__section-label { + padding: 0 2px 0 0; + } + + .folder-picker__sidebar-row { + width: auto; + flex: 0 0 auto; + } + + .folder-picker__sidebar-sublabel { + display: none; + } + + .folder-picker__main { + padding-top: 10px; + } +} + +@media (max-width: 520px) { + .folder-picker__tools { + grid-template-columns: minmax(0, 1fr); + } + + .folder-picker__main { + gap: 8px; + padding: 8px 10px 10px; + } + + .folder-picker__breadcrumb { + max-width: 120px; + } + + .folder-picker__current-path { + display: none; + } + + .folder-picker__footer-actions { + width: 100%; + justify-content: flex-end; + } +} + +@media (pointer: coarse) { + .folder-picker__sidebar-row, + .folder-picker__icon-button, + .folder-picker__breadcrumb, + .folder-picker__go, + .folder-picker__retry, + .folder-picker__row-open, + .folder-picker__choose { + min-height: 44px; + } + + .folder-picker__icon-button { + min-width: 44px; + flex-basis: 44px; + } + + .folder-picker__choose { + opacity: 1; + } +} + +@media (prefers-reduced-transparency: reduce) { + .folder-picker-dialog, + .folder-picker__sidebar, + .folder-picker__main, + .folder-picker__location, + .folder-picker__field, + .folder-picker__list { + background: var(--surface-raised-stronger-non-alpha); + } +} + +@media (prefers-reduced-motion: reduce) { + .folder-picker__loading > span { + width: 100%; + animation: none; + } +} diff --git a/frontend/workspace/src/atlas/FolderPicker.test.ts b/frontend/workspace/src/atlas/FolderPicker.test.ts new file mode 100644 index 00000000..57174446 --- /dev/null +++ b/frontend/workspace/src/atlas/FolderPicker.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = () => readFileSync(fileURLToPath(new URL("./FolderPicker.tsx", import.meta.url)), "utf8") +const styles = () => readFileSync(fileURLToPath(new URL("./FolderPicker.css", import.meta.url)), "utf8") + +describe("folder picker design contract", () => { + test("uses a scoped, responsive browser surface without inline visual state", () => { + const code = source() + const css = styles() + + expect(code).toContain('class="folder-picker-dialog"') + expect(code).toContain('class="folder-picker"') + expect(code).toContain('import "./FolderPicker.css"') + expect(code).not.toContain("style={{") + expect(code).not.toContain("onMouseEnter=") + expect(code).not.toContain("onMouseLeave=") + expect(css).toContain("grid-template-columns: 188px minmax(0, 1fr)") + expect(css).toContain("@media (max-width: 620px)") + expect(css).toContain("grid-template-rows: auto minmax(0, 1fr)") + expect(css).not.toContain("text-transform") + }) + + test("keeps breadcrumb navigation aligned and built from canonical icons", () => { + const code = source() + const css = styles() + + expect(code).toContain('import { Icon, type IconProps } from "@synsci/ui/icon"') + expect(code).toContain('<nav class="folder-picker__location" aria-label="Current folder">') + expect(code).toContain('name="arrow-up"') + expect(code).toContain('name="chevron-right"') + expect(code).toContain('name="home"') + expect(code).toContain('name="refresh"') + expect(code).toContain('label: "Desktop", path: h + "/Desktop", icon: "layout-grid"') + expect(code).toContain('label: "Documents", path: h + "/Documents", icon: "file"') + expect(code).toContain('label: "Downloads", path: h + "/Downloads", icon: "download"') + expect(code).not.toContain('from "@/atlas/shared/Icon"') + expect(css).toMatch(/\.folder-picker__location\s*\{[^}]*align-items: center/s) + expect(css).toMatch(/\.folder-picker__breadcrumbs\s*\{[^}]*overflow-x: auto/s) + expect(css).toMatch(/\.folder-picker__icon-button\s*\{[^}]*width: 32px;[^}]*height: 32px/s) + expect(css).not.toContain("linear-gradient") + }) + + test("preserves path validation and the macOS explicit-path fallback", () => { + const code = source() + + expect(code).toContain('if (trimmed === "~") return home()') + expect(code).toContain('if (trimmed.startsWith("~/")) return home() + trimmed.slice(1)') + expect(code).toContain("const valid = await validateDirectoryPath(sdk.url, abs)") + expect(code).toContain("if (!valid) return") + expect(code).toContain("setCwd(valid)") + expect(code).toContain("const valid = await validateDirectoryPath(sdk.url, cwd())") + expect(code).toContain("if (valid) pick(valid)") + expect(code).toContain('aria-label="Go to path"') + expect(code).toContain("macOS can hide Desktop") + expect(code).not.toContain("bs-local") + }) + + test("keeps explicit selection actions and accessible touch geometry", () => { + const code = source() + const css = styles() + + expect(code).toContain('class="folder-picker__row-open"') + expect(code).toContain('class="folder-picker__choose"') + expect(code).toContain('title="Choose this folder"') + expect(code).toContain('size="normal" variant="ghost" onClick={cancel}') + expect(code).toContain('size="normal"\n variant="primary"') + expect(code).not.toContain('role="button"') + expect(css).toContain("@media (pointer: coarse)") + expect(css).toMatch(/@media \(pointer: coarse\)[\s\S]*min-height: 44px/) + }) + + test("uses the shared radius ladder and solid structural boundaries", () => { + const css = styles() + + expect(css).toMatch( + /\.folder-picker-dialog\s*\{[^}]*border: 1px solid var\(--border-base\);[^}]*border-radius: var\(--atlas-radius-md\)/s, + ) + expect(css).toMatch( + /\.folder-picker__list\s*\{[^}]*border: 1px solid var\(--border-weak-base\);[^}]*border-radius: var\(--atlas-radius-sm\)/s, + ) + expect(css).toContain("border-radius: var(--atlas-radius-xs)") + expect(css).not.toMatch(/border-radius:\s*\d+(?:\.\d+)?px/) + expect(css).not.toMatch(/border(?:-(?:top|right|bottom|left))?:\s*[^;\n]*color-mix/) + }) +}) diff --git a/frontend/workspace/src/atlas/FolderPicker.tsx b/frontend/workspace/src/atlas/FolderPicker.tsx index a8bb7e47..600c6693 100644 --- a/frontend/workspace/src/atlas/FolderPicker.tsx +++ b/frontend/workspace/src/atlas/FolderPicker.tsx @@ -1,20 +1,12 @@ import { createSignal, createMemo, createResource, createEffect, type JSX, For, Show } from "solid-js" import { Dialog } from "@synsci/ui/dialog" +import { Button } from "@synsci/ui/button" +import { Icon, type IconProps } from "@synsci/ui/icon" import { useDialog } from "@synsci/ui/context/dialog" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" -import { FONT_MONO, FONT_SANS } from "@/styles/tokens" import { validateDirectoryPath } from "@/atlas/openDirectory" -import { - IconFolder, - IconChevronLeft, - IconChevronRight, - IconArrowRight, - IconFile, - IconSearch, - IconRefresh, - IconHome, -} from "@/atlas/shared/Icon" +import "./FolderPicker.css" interface FolderEntry { name: string @@ -198,12 +190,12 @@ export function FolderPicker(props: PickerProps): JSX.Element { const sidebarLinks = createMemo(() => { const h = home() - const links: Array<{ label: string; path: string; key: string }> = [ - { label: "Home", path: h, key: "home" }, - { label: "Desktop", path: h + "/Desktop", key: "desktop" }, - { label: "Documents", path: h + "/Documents", key: "docs" }, - { label: "Downloads", path: h + "/Downloads", key: "dl" }, - { label: "Applications", path: "/Applications", key: "apps" }, + const links: Array<{ label: string; path: string; icon: IconProps["name"] }> = [ + { label: "Home", path: h, icon: "home" }, + { label: "Desktop", path: h + "/Desktop", icon: "layout-grid" }, + { label: "Documents", path: h + "/Documents", icon: "file" }, + { label: "Downloads", path: h + "/Downloads", icon: "download" }, + { label: "Applications", path: "/Applications", icon: "folder-tree" }, ] return links }) @@ -214,277 +206,161 @@ export function FolderPicker(props: PickerProps): JSX.Element { <Dialog title={props.title ?? (props.kind === "file" ? "Choose a file" : "Choose a folder")} size="large" + class="folder-picker-dialog" transition > - <div - style={{ - display: "flex", - gap: "16px", - "min-height": "480px", - "max-height": "560px", - }} - > - {/* Sidebar */} - <div - style={{ - flex: "0 0 196px", - display: "flex", - "flex-direction": "column", - gap: "14px", - "border-right": "1px solid var(--color-border)", - "padding-right": "16px", - overflow: "auto", - }} - > - <div style={{ display: "flex", "flex-direction": "column", gap: "1px" }}> + <div class="folder-picker"> + <aside class="folder-picker__sidebar" aria-label="Folder locations"> + <div class="folder-picker__sidebar-group"> <SectionLabel>Favorites</SectionLabel> - <For each={sidebarLinks()}> - {(l) => <SidebarRow label={l.label} active={cwd() === l.path} onClick={() => goTo(l.path)} />} - </For> - </div> - <Show when={recents().length > 0}> - <div style={{ display: "flex", "flex-direction": "column", gap: "1px" }}> - <SectionLabel>Recent</SectionLabel> - <For each={recents()}> - {(p) => ( + <div class="folder-picker__sidebar-list"> + <For each={sidebarLinks()}> + {(location) => ( <SidebarRow - label={p.split("/").filter(Boolean).pop() ?? "/"} - sublabel={p.replace(home() + "/", "~/").replace(home(), "~")} - active={cwd() === p} - onClick={() => goTo(p)} - onDblClick={() => (props.kind === "file" ? goTo(p) : pick(p))} + label={location.label} + icon={location.icon} + active={cwd() === location.path} + onClick={() => goTo(location.path)} /> )} </For> </div> + </div> + <Show when={recents().length > 0}> + <div class="folder-picker__sidebar-group"> + <SectionLabel>Recent</SectionLabel> + <div class="folder-picker__sidebar-list"> + <For each={recents()}> + {(path) => ( + <SidebarRow + label={path.split("/").filter(Boolean).pop() ?? "/"} + sublabel={path.replace(home() + "/", "~/").replace(home(), "~")} + icon="folder" + active={cwd() === path} + onClick={() => goTo(path)} + onDblClick={() => (props.kind === "file" ? goTo(path) : pick(path))} + /> + )} + </For> + </div> + </div> </Show> - </div> + </aside> - {/* Main pane */} - <div - style={{ - flex: 1, - display: "flex", - "flex-direction": "column", - gap: "10px", - "min-width": 0, - }} - > - {/* Breadcrumbs */} - <div - style={{ - display: "flex", - "align-items": "center", - gap: "6px", - padding: "6px 8px", - background: "var(--color-bg-subtle)", - border: "1px solid var(--color-border)", - "border-radius": "10px", - "flex-wrap": "wrap", - }} - > + <section class="folder-picker__main" aria-label="Folder browser"> + <nav class="folder-picker__location" aria-label="Current folder"> + <div class="folder-picker__navigation"> + <button + type="button" + class="folder-picker__icon-button" + onClick={goUp} + aria-label="Parent folder" + title="Parent folder" + disabled={cwd() === "/" || cwd() === ""} + > + <Icon name="arrow-up" size="small" /> + </button> + <button + type="button" + class="folder-picker__icon-button" + onClick={() => goTo(home())} + aria-label="Home folder" + title="Home folder" + > + <Icon name="home" size="small" /> + </button> + </div> + <span class="folder-picker__location-divider" aria-hidden="true" /> + <div class="folder-picker__breadcrumbs"> + <For each={crumbs()}> + {(crumb, index) => ( + <> + <Show when={index() > 0}> + <span class="folder-picker__breadcrumb-separator" aria-hidden="true"> + <Icon name="chevron-right" size="small" /> + </span> + </Show> + <button + type="button" + class="folder-picker__breadcrumb" + data-current={index() === crumbs().length - 1 ? "true" : undefined} + aria-current={index() === crumbs().length - 1 ? "location" : undefined} + onClick={() => goTo(crumb.path)} + title={crumb.path} + > + {crumb.label} + </button> + </> + )} + </For> + </div> <button - onClick={goUp} - title="Parent folder" - style={navBtn(cwd() === "/" || cwd() === "")} - disabled={cwd() === "/" || cwd() === ""} + type="button" + class="folder-picker__icon-button folder-picker__refresh" + onClick={() => void refetch()} + aria-label="Refresh folder" + title="Refresh folder" > - <IconChevronLeft size={14} strokeWidth={1.5} /> + <Icon name="refresh" size="small" /> </button> - <button onClick={() => goTo(home())} title="Home" style={navBtn(false)}> - <IconHome size={14} strokeWidth={1.5} /> - </button> - <span style={{ width: "1px", height: "16px", background: "var(--color-border)" }} /> - <For each={crumbs()}> - {(c, i) => ( - <> - <Show when={i() > 0}> - <span style={{ color: "var(--color-text-faint)" }}>/</span> - </Show> - <button - onClick={() => goTo(c.path)} - style={{ - all: "unset", - cursor: "pointer", - "font-family": FONT_MONO, - "font-size": "12px", - color: i() === crumbs().length - 1 ? "var(--color-text)" : "var(--color-text-muted)", - "font-weight": i() === crumbs().length - 1 ? 600 : 500, - padding: "2px 4px", - "border-radius": "6px", - transition: "background 120ms ease, color 120ms ease", - }} - onMouseEnter={(el) => { - el.currentTarget.style.background = "var(--color-accent-subtle)" - el.currentTarget.style.color = "var(--color-text)" - }} - onMouseLeave={(el) => { - el.currentTarget.style.background = "transparent" - el.currentTarget.style.color = - i() === crumbs().length - 1 ? "var(--color-text)" : "var(--color-text-muted)" - }} - > - {c.label} - </button> - </> - )} - </For> - <span style={{ flex: 1 }} /> - <button onClick={() => refetch()} title="Refresh" style={navBtn(false)}> - <IconRefresh size={14} strokeWidth={1.5} /> - </button> - </div> - - {/* Filter */} - <div - style={{ - display: "flex", - "align-items": "center", - gap: "6px", - padding: "6px 10px", - border: "1px solid var(--color-border)", - "border-radius": "10px", - background: "var(--color-surface-solid)", - }} - > - <IconSearch size={14} strokeWidth={1.5} /> - <input - value={filter()} - onInput={(e) => setFilter(e.currentTarget.value)} - placeholder={props.kind === "file" ? "Filter files and folders…" : "Filter folders…"} - autofocus - style={{ - all: "unset", - flex: 1, - "font-family": FONT_SANS, - "font-size": "13px", - color: "var(--color-text)", - padding: "3px 10px", - }} - /> - <span - class="tab-fig" - style={{ - "font-family": FONT_MONO, - "font-size": "10px", - color: "var(--color-text-faint)", - "letter-spacing": "0.04em", - }} - > - {filtered().length} {filtered().length === 1 ? "item" : "items"} - </span> - </div> - - {/* Always-visible "paste a path" — bypass for TCC-blocked dirs - (macOS hides ~/Desktop from non-FDA processes, leaving the - folder list empty). User pastes any absolute path here and - we jump straight there. */} - <div - style={{ - display: "flex", - "align-items": "center", - gap: "6px", - padding: "6px 10px", - border: "1px dashed var(--color-border)", - "border-radius": "10px", - background: "var(--color-bg-subtle)", - }} - > - <span - style={{ - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text-faint)", - }} - > - Path - </span> - <input - value={pathInput()} - onInput={(e) => setPathInput(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void goToTyped(pathInput()) - }} - placeholder="/Users/you/Desktop/bs-local · or paste any absolute path" - spellcheck={false} - style={{ - all: "unset", - flex: 1, - "font-family": FONT_MONO, - "font-size": "11px", - color: "var(--color-text)", - padding: "3px 10px", - }} - /> - <button - type="button" - onClick={() => void goToTyped(pathInput())} - disabled={!pathInput().trim()} - style={{ - all: "unset", - cursor: pathInput().trim() ? "pointer" : "not-allowed", - padding: "3px 10px", - "border-radius": "7px", - background: pathInput().trim() ? "var(--color-surface-solid)" : "transparent", - border: "1px solid var(--color-border)", - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text-muted)", - opacity: pathInput().trim() ? 1 : 0.5, + </nav> + + <div class="folder-picker__tools"> + <label class="folder-picker__field folder-picker__search"> + <Icon name="magnifying-glass" size="small" /> + <span class="folder-picker__visually-hidden">Filter this folder</span> + <input + value={filter()} + onInput={(e) => setFilter(e.currentTarget.value)} + placeholder={props.kind === "file" ? "Filter files and folders…" : "Filter folders…"} + autofocus + autocomplete="off" + /> + <span class="folder-picker__count tab-fig" aria-live="polite"> + {filtered().length} {filtered().length === 1 ? "item" : "items"} + </span> + </label> + + {/* Keep the path field always available. macOS can hide Desktop, + Documents, and Downloads from directory listings even when an + explicitly entered path remains valid and usable. */} + <form + class="folder-picker__field folder-picker__path-field" + onSubmit={(event) => { + event.preventDefault() + void goToTyped(pathInput()) }} > - Go - </button> + <Icon name="folder-tree" size="small" /> + <span class="folder-picker__field-label">Path</span> + <input + value={pathInput()} + onInput={(e) => setPathInput(e.currentTarget.value)} + aria-label="Go to path" + placeholder="/Users/you/research or ~/research" + spellcheck={false} + autocomplete="off" + /> + <button type="submit" class="folder-picker__go" disabled={!pathInput().trim()}> + Go + </button> + </form> </div> - {/* Folder list */} <div - class="atlas-scroll" + class="folder-picker__list atlas-scroll" + classList={{ "folder-picker__list--loading": entries.loading }} + aria-busy={entries.loading} ref={(el) => { - // Reset scroll position whenever the user navigates so the new - // folder always starts at the top instead of carrying the prior - // scroll offset (which feels jumpy mid-navigation). createEffect(() => { cwd() el.scrollTop = 0 }) }} - style={{ - flex: 1, - "overflow-y": "auto", - border: "1px solid var(--color-border)", - "border-radius": "10px", - background: "var(--color-surface-solid)", - "min-height": "240px", - position: "relative", - // Slight desaturation while loading hints at activity without - // unmounting the rows — feels much smoother than a full swap. - opacity: entries.loading ? 0.55 : 1, - transition: "opacity 120ms ease", - }} > - {/* Thin indeterminate loading bar across the top while fetching. */} <Show when={entries.loading}> - <div - style={{ - position: "absolute", - top: 0, - left: 0, - right: 0, - height: "2px", - overflow: "hidden", - "pointer-events": "none", - "z-index": 1, - }} - > - <div - style={{ - width: "30%", - height: "100%", - background: "linear-gradient(90deg, transparent, var(--color-accent), transparent)", - animation: "atlas-loading-slide 1.1s ease-in-out infinite", - }} - /> + <div class="folder-picker__loading" role="progressbar" aria-label="Loading folder"> + <span /> </div> </Show> <Show @@ -494,56 +370,17 @@ export function FolderPicker(props: PickerProps): JSX.Element { <Show when={!error()} fallback={ - <div - class="atlas-fade-in" - style={{ - padding: "32px 24px", - "text-align": "center", - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-error)", - display: "flex", - "flex-direction": "column", - "align-items": "center", - gap: "10px", - }} - > - <span>Couldn’t read this folder</span> - <span style={{ color: "var(--color-text-faint)", "max-width": "360px", "line-height": 1.5 }}> - {error()} - </span> - <button - type="button" - onClick={() => void refetch()} - style={{ - all: "unset", - cursor: "pointer", - padding: "5px 12px", - "border-radius": "8px", - border: "1px solid var(--color-border)", - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text)", - }} - > + <div class="folder-picker__empty folder-picker__empty--error atlas-fade-in" role="alert"> + <Icon name="alert-circle" size="normal" /> + <strong>Couldn’t read this folder</strong> + <p>{error()}</p> + <button type="button" class="folder-picker__retry" onClick={() => void refetch()}> Retry </button> </div> } > - <div - class="atlas-fade-in" - style={{ - padding: "32px 24px", - "text-align": "center", - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text-faint)", - display: "flex", - "flex-direction": "column", - gap: "8px", - }} - > + <div class="folder-picker__empty atlas-fade-in"> <Show when={(entries() ?? []).length === 0} fallback={<span>Nothing matches the filter.</span>}> <Show when={ @@ -560,14 +397,14 @@ export function FolderPicker(props: PickerProps): JSX.Element { </span> } > - <span style={{ color: "var(--color-text)" }}> + <strong> macOS is blocking the listing of <code>{cwd().split("/").pop()}</code> - </span> - <span style={{ "max-width": "360px", "line-height": 1.5 }}> - To list this folder we'd need Full Disk Access for the - <code>openscience</code> binary. For now, paste the absolute path of the folder you want - into the <em>go to</em> bar above — OpenScience can still open any path you give it. - </span> + </strong> + <p> + To list this folder, the <code>openscience</code> binary needs Full Disk Access. For now, + paste the absolute path of the folder you want into the path field above. OpenScience can + still open a path you provide explicitly. + </p> </Show> </Show> </div> @@ -588,47 +425,33 @@ export function FolderPicker(props: PickerProps): JSX.Element { </Show> </div> - {/* Footer */} - <div - style={{ - display: "flex", - "align-items": "center", - gap: "8px", - "padding-top": "4px", - }} - > - <span - style={{ - "font-family": FONT_MONO, - "font-size": "10px", - color: "var(--color-text-faint)", - flex: 1, - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - title={cwd()} - > + <footer class="folder-picker__footer"> + <span class="folder-picker__current-path" title={cwd()}> + <Icon name="folder" size="small" /> {cwd().replace(home(), "~")} </span> - <button onClick={cancel} style={cancelBtn()}> - Cancel - </button> - <Show when={props.kind !== "file"}> - <button - onClick={async () => { - const valid = await validateDirectoryPath(sdk.url, cwd()) - if (valid) pick(valid) - }} - title="Choose the current folder" - style={primaryBtn()} - > - <IconArrowRight size={11} strokeWidth={2} /> - Use this folder - </button> - </Show> - </div> - </div> + <div class="folder-picker__footer-actions"> + <Button type="button" size="normal" variant="ghost" onClick={cancel}> + Cancel + </Button> + <Show when={props.kind !== "file"}> + <Button + type="button" + size="normal" + variant="primary" + onClick={async () => { + const valid = await validateDirectoryPath(sdk.url, cwd()) + if (valid) pick(valid) + }} + title="Choose the current folder" + > + Use this folder + <Icon name="arrow-right" size="small" /> + </Button> + </Show> + </div> + </footer> + </section> </div> </Dialog> ) @@ -640,231 +463,60 @@ function PickerRow(props: { onPick: () => void pickingFile: boolean }): JSX.Element { - const [hover, setHover] = createSignal(false) const folder = () => props.entry.type === "directory" return ( - <div - role="button" - tabindex="0" - onClick={props.onOpen} - onDblClick={() => (folder() ? props.onPick() : undefined)} - onKeyDown={(e) => { - if (e.key === "Enter") props.onOpen() - }} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - title={ - folder() ? `${props.entry.absolute} · click to enter` : `${props.entry.absolute} · click to choose this file` - } - style={{ - cursor: "pointer", - display: "flex", - "align-items": "center", - gap: "10px", - padding: "9px 12px", - "border-bottom": "1px solid var(--color-border)", - background: hover() ? "var(--color-accent-subtle)" : "transparent", - transform: hover() ? "translateX(2px)" : "translateX(0)", - transition: "background 160ms ease, transform 160ms ease", - }} - > - <Show when={folder()} fallback={<IconFile size={15} strokeWidth={1.5} />}> - <IconFolder size={15} strokeWidth={1.5} /> - </Show> - <span - style={{ - flex: 1, - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - "font-family": FONT_SANS, - "font-size": "13px", - color: "var(--color-text)", - }} + <div class="folder-picker__row" data-kind={props.entry.type}> + <button + type="button" + class="folder-picker__row-open" + onClick={props.onOpen} + onDblClick={() => (folder() ? props.onPick() : undefined)} + title={folder() ? `${props.entry.absolute} · open folder` : `${props.entry.absolute} · choose this file`} > - {props.entry.name} - </span> - <Show when={folder()} fallback={<span style={chooseLabel(hover())}>Choose</span>}> - <Show when={!props.pickingFile}> - <button - type="button" - onClick={(event) => { - event.stopPropagation() - props.onPick() - }} - title="Choose this folder" - style={chooseButton(hover())} - > - Choose - </button> + <Icon name={folder() ? "folder" : "file"} size="small" /> + <span class="folder-picker__row-name">{props.entry.name}</span> + <Show when={folder()} fallback={<span class="folder-picker__pick-label">Choose</span>}> + <span class="folder-picker__row-chevron" aria-hidden="true"> + <Icon name="chevron-right" size="small" /> + </span> </Show> - <IconChevronRight - size={11} - strokeWidth={1.5} - style={{ - opacity: hover() ? 1 : 0.5, - transform: hover() ? "translateX(2px)" : "translateX(0)", - transition: "opacity 160ms ease, transform 160ms ease", - }} - /> + </button> + <Show when={folder() && !props.pickingFile}> + <button type="button" class="folder-picker__choose" onClick={props.onPick} title="Choose this folder"> + Choose + </button> </Show> </div> ) } -const chooseButton = (visible: boolean): JSX.CSSProperties => ({ - all: "unset", - cursor: "pointer", - padding: "2px 8px", - "border-radius": "7px", - "font-family": FONT_SANS, - "font-size": "11px", - color: "var(--color-text-muted)", - border: "1px solid var(--color-border)", - background: "var(--color-surface-solid)", - opacity: visible ? 1 : 0, - transform: visible ? "translateX(0)" : "translateX(4px)", - "pointer-events": visible ? "auto" : "none", - transition: "opacity 160ms ease, transform 160ms ease", -}) - -const chooseLabel = (visible: boolean): JSX.CSSProperties => ({ - "font-family": FONT_SANS, - "font-size": "11px", - color: "var(--color-text-muted)", - opacity: visible ? 1 : 0.55, -}) - function SectionLabel(props: { children: JSX.Element }): JSX.Element { - return ( - <div - style={{ - "font-family": FONT_SANS, - "font-size": "11px", - "font-weight": 600, - color: "var(--color-text-faint)", - padding: "5px 8px", - }} - > - {props.children} - </div> - ) + return <span class="folder-picker__section-label">{props.children}</span> } function SidebarRow(props: { label: string sublabel?: string + icon: IconProps["name"] active: boolean onClick: () => void onDblClick?: () => void }): JSX.Element { return ( - <div - role="button" - tabindex="0" + <button + type="button" + class="folder-picker__sidebar-row" + data-active={props.active ? "true" : undefined} onClick={props.onClick} onDblClick={props.onDblClick} - onKeyDown={(e) => { - if (e.key === "Enter") props.onClick() - }} - style={{ - cursor: "pointer", - display: "flex", - "align-items": "center", - gap: "8px", - padding: "6px 8px", - "border-radius": "8px", - background: props.active ? "var(--color-bg-elevated)" : "transparent", - border: props.active ? "1px solid var(--color-border-strong)" : "1px solid transparent", - transition: "background 160ms ease, border-color 160ms ease, transform 160ms ease", - }} - onMouseEnter={(el) => { - if (!props.active) el.currentTarget.style.background = "var(--color-accent-subtle)" - el.currentTarget.style.transform = "translateX(2px)" - }} - onMouseLeave={(el) => { - if (!props.active) el.currentTarget.style.background = "transparent" - el.currentTarget.style.transform = "translateX(0)" - }} > - <IconFolder size={14} strokeWidth={1.5} /> - <div style={{ flex: 1, "min-width": 0, display: "flex", "flex-direction": "column" }}> - <span - style={{ - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-text)", - "font-weight": props.active ? 600 : 500, - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - > - {props.label} - </span> + <Icon name={props.icon} size="small" /> + <span class="folder-picker__sidebar-copy"> + <span class="folder-picker__sidebar-label">{props.label}</span> <Show when={props.sublabel}> - <span - style={{ - "font-family": FONT_MONO, - "font-size": "10px", - color: "var(--color-text-faint)", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - > - {props.sublabel} - </span> + <span class="folder-picker__sidebar-sublabel">{props.sublabel}</span> </Show> - </div> - </div> + </span> + </button> ) } - -function navBtn(disabled: boolean): JSX.CSSProperties { - return { - all: "unset", - cursor: disabled ? "not-allowed" : "pointer", - display: "inline-flex", - "align-items": "center", - "justify-content": "center", - width: "28px", - height: "28px", - "border-radius": "7px", - color: "var(--color-text-muted)", - background: "var(--color-surface-solid)", - border: "1px solid var(--color-border)", - opacity: disabled ? 0.4 : 1, - } as JSX.CSSProperties -} - -function cancelBtn(): JSX.CSSProperties { - return { - all: "unset", - cursor: "pointer", - padding: "6px 12px", - "border-radius": "8px", - border: "1px solid var(--color-border)", - background: "var(--color-surface-solid)", - "font-family": FONT_SANS, - "font-size": "13px", - color: "var(--color-text-muted)", - } as JSX.CSSProperties -} - -function primaryBtn(): JSX.CSSProperties { - return { - all: "unset", - cursor: "pointer", - padding: "6px 14px", - "border-radius": "8px", - background: "var(--color-accent)", - color: "var(--color-on-accent)", - "font-family": FONT_SANS, - "font-size": "13px", - "font-weight": 500, - display: "inline-flex", - "align-items": "center", - gap: "6px", - } as JSX.CSSProperties -} diff --git a/frontend/workspace/src/atlas/HelpOverlay.test.ts b/frontend/workspace/src/atlas/HelpOverlay.test.ts new file mode 100644 index 00000000..519a91f7 --- /dev/null +++ b/frontend/workspace/src/atlas/HelpOverlay.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" + +const source = await Bun.file(new URL("./HelpOverlay.tsx", import.meta.url)).text() + +test("uses the shared modal foundation with focus containment and restoration", () => { + expect(source).toContain('import { Dialog as Kobalte } from "@kobalte/core/dialog"') + expect(source).toContain("<Kobalte.Overlay") + expect(source).toContain("<Kobalte.Content") + expect(source).toContain("<Kobalte.Title") + expect(source).toContain("<Kobalte.CloseButton") + expect(source).toContain("onOpenAutoFocus") + expect(source).toContain("onCloseAutoFocus") + expect(source).toContain("closeRef?.focus()") + expect(source).toContain("if (restoreFocus?.isConnected) restoreFocus.focus()") + expect(source).toContain("command.keybinds(false)") + expect(source).toContain("command.keybinds(true)") + expect(source).not.toContain('window.addEventListener("keydown"') + expect(source).not.toContain('from "solid-js/web"') +}) + +test("exposes a named dialog and a useful close target", () => { + expect(source).toContain("Keyboard shortcuts") + expect(source).toContain('aria-label="Close keyboard shortcuts"') + expect(source).toContain('width: "40px"') + expect(source).toContain('height: "40px"') +}) + +test("uses sentence case and reserves monospace for shortcut syntax", () => { + expect(source).toContain('label: "Open the command palette"') + expect(source).toContain('label: "Create a project or session"') + expect(source).toContain('label: "Insert a new line"') + expect(source).toContain('"font-family": FONT_SANS') + expect(source).toContain('"font-family": FONT_MONO') +}) diff --git a/frontend/workspace/src/atlas/HelpOverlay.tsx b/frontend/workspace/src/atlas/HelpOverlay.tsx index 3074e3c3..282b5c99 100644 --- a/frontend/workspace/src/atlas/HelpOverlay.tsx +++ b/frontend/workspace/src/atlas/HelpOverlay.tsx @@ -1,8 +1,9 @@ -import { type JSX, Show, For, onMount, onCleanup } from "solid-js" -import { Portal } from "solid-js/web" +import { createEffect, type JSX, For, onCleanup } from "solid-js" +import { Dialog as Kobalte } from "@kobalte/core/dialog" import { FONT_MONO, FONT_SANS } from "@/styles/tokens" import { IconX } from "@/atlas/shared/Icon" import { AgentIcon } from "@/atlas/shared/AgentIcon" +import { useCommand } from "@/context/command" interface HelpOverlayProps { open: boolean @@ -11,48 +12,64 @@ interface HelpOverlayProps { const SECTIONS: Array<{ title: string; rows: Array<{ keys: string[]; label: string }> }> = [ { - title: "navigation", + title: "Navigation", rows: [ - { keys: ["⌘", "K"], label: "command palette" }, - { keys: ["⌘", "N"], label: "open folder / new project" }, - { keys: ["?"], label: "open this help" }, + { keys: ["⌘", "K"], label: "Open the command palette" }, + { keys: ["⌘", "N"], label: "Create a project or session" }, + { keys: ["?"], label: "Open keyboard shortcuts" }, ], }, { - title: "chat", + title: "Chat", rows: [ - { keys: ["↵"], label: "send message" }, - { keys: ["⇧", "↵"], label: "newline in composer" }, - { keys: ["/"], label: "skill menu" }, - { keys: ["esc"], label: "close modal" }, + { keys: ["↵"], label: "Send message" }, + { keys: ["⇧", "↵"], label: "Insert a new line" }, + { keys: ["/"], label: "Open skills and commands" }, + { keys: ["Esc"], label: "Close the active dialog" }, ], }, { - title: "sessions", - rows: [{ keys: ["dbl-click"], label: "rename a session" }], + title: "Sessions", + rows: [{ keys: ["Double-click"], label: "Rename a session" }], }, ] export function HelpOverlay(props: HelpOverlayProps): JSX.Element { - onMount(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && props.open) props.onClose() - } - window.addEventListener("keydown", onKey) - onCleanup(() => window.removeEventListener("keydown", onKey)) + const command = useCommand() + let closeRef: HTMLButtonElement | undefined + let restoreFocus: HTMLElement | undefined + + createEffect(() => { + if (!props.open) return + command.keybinds(false) + onCleanup(() => command.keybinds(true)) }) return ( - <Show when={props.open}> - <Portal> - <div class="atlas-overlay" onClick={props.onClose} /> - <div + <Kobalte + modal + open={props.open} + onOpenChange={(open) => { + if (!open) props.onClose() + }} + > + <Kobalte.Portal> + <Kobalte.Overlay class="atlas-overlay" /> + <Kobalte.Content class="atlas-modal" role="dialog" aria-modal="true" - aria-label="keyboard shortcuts" style={{ width: "560px", "max-width": "94vw" }} - onClick={(e) => e.stopPropagation()} + onOpenAutoFocus={(event) => { + event.preventDefault() + const active = document.activeElement + restoreFocus = active instanceof HTMLElement && active !== document.body ? active : undefined + closeRef?.focus() + }} + onCloseAutoFocus={(event) => { + event.preventDefault() + if (restoreFocus?.isConnected) restoreFocus.focus() + }} > <div style={{ @@ -64,31 +81,39 @@ export function HelpOverlay(props: HelpOverlayProps): JSX.Element { }} > <AgentIcon size={20} strokeWidth={1.5} /> - <span + <Kobalte.Title style={{ "font-family": FONT_SANS, "font-size": "22px", + "font-weight": "var(--font-weight-medium)", + "line-height": 1.2, "letter-spacing": "-0.01em", + margin: 0, color: "var(--color-text)", }} > - keyboard shortcuts - </span> + Keyboard shortcuts + </Kobalte.Title> <span style={{ flex: 1 }} /> - <button + <Kobalte.CloseButton + ref={closeRef} type="button" aria-label="Close keyboard shortcuts" - onClick={props.onClose} style={{ all: "unset", + "box-sizing": "border-box", cursor: "pointer", color: "var(--color-text-faint)", display: "inline-flex", - padding: "4px", + width: "40px", + height: "40px", + "align-items": "center", + "justify-content": "center", + "border-radius": "6px", }} > <IconX size={14} strokeWidth={1.5} /> - </button> + </Kobalte.CloseButton> </div> <div class="atlas-scroll" @@ -108,8 +133,7 @@ export function HelpOverlay(props: HelpOverlayProps): JSX.Element { style={{ "font-family": FONT_MONO, "font-size": "10px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", + "letter-spacing": "normal", color: "var(--color-text-faint)", }} > @@ -162,8 +186,8 @@ export function HelpOverlay(props: HelpOverlayProps): JSX.Element { )} </For> </div> - </div> - </Portal> - </Show> + </Kobalte.Content> + </Kobalte.Portal> + </Kobalte> ) } diff --git a/frontend/workspace/src/atlas/HostStrip.css b/frontend/workspace/src/atlas/HostStrip.css index 8c700a4b..4ce9d9d7 100644 --- a/frontend/workspace/src/atlas/HostStrip.css +++ b/frontend/workspace/src/atlas/HostStrip.css @@ -2,31 +2,29 @@ display: grid; min-width: 0; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) minmax(112px, 0.8fr); - gap: 0; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg); + gap: 4px; + margin: 12px 12px 0; + border-radius: var(--atlas-radius-sm); + background: color-mix(in srgb, var(--color-bg-subtle) 72%, var(--color-bg)); + padding: 4px; } .host-strip__metric { display: flex; min-width: 0; - min-height: 74px; + min-height: 60px; box-sizing: border-box; justify-content: center; flex-direction: column; - gap: 7px; - padding: 11px 14px; - border-right: 1px solid var(--color-border); -} - -.host-strip__metric:last-child { - border-right: 0; + gap: 6px; + padding: 8px 10px; + border-radius: var(--atlas-radius-xs); } .host-strip__label { - color: var(--color-text-faint); - font-size: 10px; - font-weight: 500; + color: var(--color-text-muted); + font-size: 11px; + font-weight: var(--font-weight-regular); } .host-strip__metric p { @@ -37,14 +35,18 @@ margin: 0; overflow: hidden; color: var(--color-text-muted); - font-size: 10px; + font-size: 12px; white-space: nowrap; } +.host-strip__metric--kernels { + align-items: flex-start; +} + .host-strip__metric strong { color: var(--color-text); font-size: 15px; - font-weight: 500; + font-weight: var(--font-weight-medium); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } @@ -57,17 +59,17 @@ .host-strip__meter { display: block; width: 100%; - height: 3px; + height: 2px; overflow: hidden; border-radius: 999px; - background: var(--color-border); + background: var(--border-weak-base); } .host-strip__meter i { display: block; height: 100%; border-radius: inherit; - background: var(--color-text-muted); + background: color-mix(in srgb, var(--color-text-muted) 72%, var(--color-bg)); transition: width 180ms ease; } @@ -77,26 +79,35 @@ @container compute (max-width: 500px) { .host-strip { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.16fr) minmax(0, 1fr) auto; } .host-strip__metric { - min-height: 68px; - padding: 10px 12px; - } - - .host-strip__metric:nth-child(2) { - border-right: 0; + min-height: 58px; + padding: 8px; } .host-strip__metric--kernels { - min-height: 42px; - grid-column: 1 / -1; - border-top: 1px solid var(--color-border); - border-right: 0; + min-width: 58px; } .host-strip__metric--kernels .host-strip__label { + overflow: hidden; + max-width: 58px; + text-overflow: ellipsis; + } +} + +@container compute (max-width: 360px) { + .host-strip { + margin-inline: 8px; + } + + .host-strip__metric { + padding-inline: 7px; + } + + .host-strip__metric p > span { display: none; } } diff --git a/frontend/workspace/src/atlas/HostStrip.test.ts b/frontend/workspace/src/atlas/HostStrip.test.ts index 5f438f88..0f98a2a7 100644 --- a/frontend/workspace/src/atlas/HostStrip.test.ts +++ b/frontend/workspace/src/atlas/HostStrip.test.ts @@ -35,17 +35,16 @@ const capacity = { kernels: { live: 2, running: 1 }, } -// A live Bun.serve endpoint cannot stand in for the product server under this +// A live test endpoint cannot stand in for the product server under this // suite: happydom.ts replaces globalThis.Response, so Bun.serve does not // recognise what a handler returns and answers with its own placeholder body // carrying a doubled content-length. Every request would then fail for a reason // that has nothing to do with the subject, and the degraded-state tests would // pass without the error status ever reaching the component. So the connection -// failure uses a genuinely closed port over Bun's own fetch, and the statuses a -// running server returns are real Response objects over fixture bodies. -const closed = Bun.serve({ port: 0, fetch: () => new Response("") }) -const unreachable = `http://127.0.0.1:${closed.port}` -await closed.stop(true) +// failure uses an unbound high loopback port over Bun's own fetch, and the +// statuses a running server returns are real Response objects over fixtures. +// Avoid opening a listener here: restricted test runners may forbid all binds. +const unreachable = "http://127.0.0.1:65535" const offline = (path: string) => Bun.fetch(`${unreachable}${path}`) const erroring = async () => new Response("kernel registry unavailable", { status: 503 }) @@ -131,7 +130,8 @@ describe("host strip", () => { test("names the block as machine resources", () => { const source = readFileSync(fileURLToPath(new URL("./HostStrip.tsx", import.meta.url)), "utf8") - expect(source).toContain('<span class="host-strip__label">Machine</span>') + expect(source).toContain('<span class="host-strip__label">Memory</span>') + expect(source).toContain('<span class="host-strip__label">Active</span>') expect(source).toContain('aria-label="Machine resources"') }) @@ -142,8 +142,9 @@ describe("host strip", () => { expect(host.querySelector("[data-boundary]")).toBeNull() expect(values(host)).toEqual(["412.0 MB", "~0.4 of 8"]) - expect(host.textContent).toContain("of 16.0 GB memory") - expect(host.textContent).toContain("2kernels · 1 running") + expect(host.textContent).toContain("of 16.0 GB") + expect(host.textContent).toContain("2processes") + expect(host.querySelector('[data-host-tile="kernels"] p')?.getAttribute("title")).toContain("2 kernels · 1 running") }) test("asks the route the compute strip is served from, naming itself to the server", async () => { diff --git a/frontend/workspace/src/atlas/HostStrip.tsx b/frontend/workspace/src/atlas/HostStrip.tsx index af84ded0..57138993 100644 --- a/frontend/workspace/src/atlas/HostStrip.tsx +++ b/frontend/workspace/src/atlas/HostStrip.tsx @@ -36,6 +36,11 @@ export function HostStrip(props: HostStripProps = {}): JSX.Element { .catch(() => undefined) const [data, api] = createResource(load) const reading = createMemo(() => hostReading(data.latest)) + const processDetail = () => { + const detail = reading().kernels + if (detail === "kernel count unavailable" || /^\d/.test(detail)) return detail + return `${reading().live} ${detail}` + } const refresh = () => { if (document.hidden) return void api.refetch() @@ -50,10 +55,10 @@ export function HostStrip(props: HostStripProps = {}): JSX.Element { return ( <section class="host-strip" aria-label="Machine resources" data-testid="host-strip"> <div class="host-strip__metric" data-host-tile="memory"> - <span class="host-strip__label">Machine</span> + <span class="host-strip__label">Memory</span> <p> <strong class="host-strip__headline">{reading().headline}</strong> - <span>{reading().memory}</span> + <span>{reading().memory.replace(/ memory$/, "")}</span> </p> <Meter value={reading().memoryFill} /> </div> @@ -62,16 +67,16 @@ export function HostStrip(props: HostStripProps = {}): JSX.Element { <span class="host-strip__label">CPU</span> <p> <strong class="host-strip__cores-value">{reading().cores}</strong> - <span>cores CPU</span> + <span>cores</span> </p> <Meter value={reading().cpuFill} /> </div> <div class="host-strip__metric host-strip__metric--kernels" data-host-tile="kernels"> - <span class="host-strip__label">Live</span> - <p> + <span class="host-strip__label">Active</span> + <p title={processDetail()} aria-label={`${reading().live} active processes. ${processDetail()}`}> <strong class="host-strip__kernels-value">{reading().live}</strong> - <span>{reading().kernels}</span> + <span>processes</span> </p> </div> </section> diff --git a/frontend/workspace/src/atlas/KernelCard.test.tsx b/frontend/workspace/src/atlas/KernelCard.test.tsx index 71869a93..5ff8bbd3 100644 --- a/frontend/workspace/src/atlas/KernelCard.test.tsx +++ b/frontend/workspace/src/atlas/KernelCard.test.tsx @@ -61,12 +61,19 @@ describe("kernel status row", () => { test("shows the live runtime in one compact row", () => { const host = mount(() => subject.KernelCard({ kernel: kernel(), action: "", onControl: () => {} })) - expect(host.querySelector(".kernel-card__language")?.textContent).toBe("Py") + expect(host.querySelector(".kernel-card__language [data-component=icon]")).not.toBeNull() expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("analysis.ipynb") - expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Executing now") - expect(host.querySelectorAll(".kernel-card__metric")[0]?.textContent).toBe("412 MBrss") - expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("1.8cores") - expect(host.querySelector(".kernel-card__uptime")?.textContent).toMatch(/^\d+s$/) + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Running · 7 cells") + expect(host.querySelector(".kernel-card__copy > span")?.getAttribute("title")).toBe("Executing now.") + expect(host.querySelector(".kernel-card__uptime strong")?.textContent).toMatch(/^\d+s$/) + expect(host.querySelector(".kernel-card__uptime small")?.textContent).toBe("Runtime") + expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("412 MBMemory") + expect(host.querySelectorAll(".kernel-card__metric")[2]?.textContent).toBe("1.8CPU cores") + }) + + test("keeps queued work visible without filling the row with recovery prose", () => { + expect(subject.kernelActivity(kernel({ queue_depth: 2 }))).toBe("Running · 7 cells · 2 queued") + expect(subject.kernelActivity(kernel({ state: "idle", execution_count: 1 }))).toBe("Ready · 1 cell") }) test("only exposes stop, never manual start, restart, interrupt, or forget", () => { @@ -94,7 +101,7 @@ describe("kernel status row", () => { expect(host.querySelectorAll("button").length).toBe(1) expect(host.querySelector<HTMLButtonElement>("button")?.disabled).toBe(true) - expect(host.querySelector(".kernel-card__uptime")?.textContent).toBe("—") + expect(host.querySelector(".kernel-card__uptime strong")?.textContent).toBe("—") expect(host.textContent).not.toContain("Start") expect(host.textContent).not.toContain("Restart") }) @@ -129,7 +136,7 @@ describe("kernel status row", () => { const cell = host.querySelector<HTMLDetailsElement>(".kernel-card__cell") expect(cell?.open).toBe(false) - expect(cell?.querySelector("summary")?.textContent).toContain("Cell 7 · running") + expect(cell?.querySelector("summary")?.textContent).toContain("Cell 7 · Running") expect(cell?.querySelector("summary")?.textContent).toContain("Benchmarking survival classifiers") expect(cell?.querySelector("summary")?.textContent).toContain("analysis/titanic.ipynb") expect(cell?.querySelector("code")?.textContent).toBe("model.fit(X, y)") diff --git a/frontend/workspace/src/atlas/KernelCard.tsx b/frontend/workspace/src/atlas/KernelCard.tsx index c0468e73..d7097f7b 100644 --- a/frontend/workspace/src/atlas/KernelCard.tsx +++ b/frontend/workspace/src/atlas/KernelCard.tsx @@ -1,4 +1,5 @@ import { Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js" +import { IconBraces, IconFlask, IconTerminal } from "@/atlas/shared/Icon" import { kernelCanStop, kernelLabel, @@ -23,6 +24,12 @@ const cores = (value?: number) => { return (value / 100).toFixed(1) } +export const kernelActivity = (kernel: KernelStatus) => { + const cells = `${kernel.execution_count} ${kernel.execution_count === 1 ? "cell" : "cells"}` + const queued = kernel.queue_depth > 0 ? ` · ${kernel.queue_depth} queued` : "" + return `${kernelStateLabel(kernel.state)} · ${cells}${queued}` +} + export function KernelCard(props: { kernel: KernelStatus action: string @@ -43,32 +50,38 @@ export function KernelCard(props: { <article class="kernel-card" data-kernel-id={props.kernel.id} data-state={props.kernel.state}> <div class="kernel-card__main"> <span class="kernel-card__language" aria-hidden="true"> - {props.kernel.language === "python" ? "Py" : props.kernel.language === "r" ? "R" : "›_"} + {props.kernel.language === "python" ? ( + <IconBraces size={16} strokeWidth={1.5} /> + ) : props.kernel.language === "r" ? ( + <IconFlask size={16} strokeWidth={1.5} /> + ) : ( + <IconTerminal size={14} strokeWidth={1.4} /> + )} </span> <div class="kernel-card__copy"> <strong title={kernelLabel(props.kernel)}>{kernelLabel(props.kernel)}</strong> - <span> + <span title={kernelRecoveryLabel(props.kernel)}> <i data-tone={kernelTone(props.kernel.state)} aria-hidden="true" /> - {kernelStateLabel(props.kernel.state)} · - <span data-slot="kernel-card-executions"> - {props.kernel.execution_count} {props.kernel.execution_count === 1 ? "cell" : "cells"} - </span> - · {kernelRecoveryLabel(props.kernel)} + <span data-slot="kernel-card-executions">{kernelActivity(props.kernel)}</span> </span> </div> </div> - <span class="kernel-card__uptime" aria-label={`Uptime ${uptime()}`}> - {uptime() === "Unavailable" ? "—" : uptime()} - </span> - <Metric label="rss" value={memory(props.kernel.resources?.memory_bytes)} /> - <Metric label="cores" value={cores(props.kernel.resources?.cpu_percent)} /> + <div class="kernel-card__metrics" aria-label="Kernel resource use"> + <span class="kernel-card__uptime kernel-card__metric" aria-label={`Runtime ${uptime()}`}> + <strong>{uptime() === "Unavailable" ? "—" : uptime()}</strong> + <small>Runtime</small> + </span> + <Metric label="Memory" value={memory(props.kernel.resources?.memory_bytes)} /> + <Metric label="CPU cores" value={cores(props.kernel.resources?.cpu_percent)} /> + </div> <button type="button" class="kernel-card__stop" aria-label={`Stop ${kernelLabel(props.kernel)}`} title={`Stop this ${kernelLanguageLabel(props.kernel)} kernel and clear its in-memory state.`} disabled={!!props.action || !kernelCanStop(props.kernel)} + aria-busy={busy()} onClick={() => props.onControl("stop")} > {busy() ? "Stopping…" : "Stop"} @@ -78,7 +91,8 @@ export function KernelCard(props: { <details class="kernel-card__cell"> <summary> <span> - {cell().execution_count ? `Cell ${cell().execution_count}` : "Current cell"} · {cell().status} + {cell().execution_count ? `Cell ${cell().execution_count}` : "Current cell"} ·{" "} + {cell().status.charAt(0).toUpperCase() + cell().status.slice(1)} </span> <strong>{cell().title || `${kernelLanguageLabel(props.kernel)} cell`}</strong> <Show when={cell().source}>{(source) => <small>{source()}</small>}</Show> diff --git a/frontend/workspace/src/atlas/KernelPanel.poll.test.ts b/frontend/workspace/src/atlas/KernelPanel.poll.test.ts index 89ea4cba..c46265d4 100644 --- a/frontend/workspace/src/atlas/KernelPanel.poll.test.ts +++ b/frontend/workspace/src/atlas/KernelPanel.poll.test.ts @@ -26,13 +26,11 @@ const subject = (await vite.ssrLoadModule("/src/atlas/KernelPanel.tsx")) as type afterAll(() => vite.close()) -// A genuinely closed port, so the connection failure is the real thing rather -// than a rejection this test invented. happydom.ts replaces globalThis.Response, +// An unbound high loopback port, so the connection failure comes from a real +// fetch rather than a rejection this test invented. happydom.ts replaces globalThis.Response, // so a live Bun.serve endpoint cannot stand in for the product server here — // see HostStrip.test.ts for the full reasoning. -const closed = Bun.serve({ port: 0, fetch: () => new Response("") }) -const unreachable = `http://127.0.0.1:${closed.port}` -await closed.stop(true) +const unreachable = "http://127.0.0.1:65535" describe("kernel panel poll", () => { test("resolves to no inventory when the server cannot be reached", async () => { diff --git a/frontend/workspace/src/atlas/KernelPanel.test.ts b/frontend/workspace/src/atlas/KernelPanel.test.ts index 5020b7ca..0b636246 100644 --- a/frontend/workspace/src/atlas/KernelPanel.test.ts +++ b/frontend/workspace/src/atlas/KernelPanel.test.ts @@ -11,10 +11,10 @@ describe("live compute inventory", () => { test("is project-wide and stays mounted across session changes", () => { const panel = source() - expect(panel).toContain('aria-label="Live project compute"') + expect(panel).toContain('aria-label="Project compute"') expect(panel).toContain("[...grouped().keys()].sort") expect(panel).toContain("<For each={groups()}>") - expect(panel).toContain("<em>current</em>") + expect(panel).toContain("<em>Current</em>") expect(panel).toContain("{ client }") expect(panel).not.toContain("{ sessionID: params.id, client }") expect(panel).toContain('request<CommandsPayload>("/notebook/commands"') @@ -53,7 +53,7 @@ describe("live compute inventory", () => { expect(panel).toContain("the next agent run will start fresh") expect(panel).toContain("Its child processes were terminated") expect(panel).toContain("provider cleanup") - expect(panel).toContain("jobLive") + expect(panel).toContain("visibleJobs") }) test("polls unconditionally so an agent-started kernel appears", () => { @@ -70,11 +70,19 @@ describe("live compute inventory", () => { test("explains idle and degraded states without claiming a failed poll is empty", () => { const panel = source() - expect(panel).toContain("No live compute") - expect(panel).toContain( - "Kernels, commands, and remote jobs appear here the moment any session starts computing in this project.", - ) - expect(panel).toContain('{view.error ? "Compute inventory unavailable" : "No live compute"}') + expect(panel).toContain("Runtimes start with your work") + expect(panel).toContain("Python and R kernels preserve session state automatically") + expect(panel).toContain('{view.error ? "Compute inventory unavailable" : "Runtimes start with your work"}') expect(panel).toContain("The last poll could not read this project's kernels, commands, and remote jobs") }) + + test("uses compact session summaries instead of a single wrapping status sentence", () => { + const panel = source() + + expect(panel).toContain('class="kernel-session__summary"') + expect(panel).toContain("summary().kinds") + expect(panel).toContain("summary().memory") + expect(panel).toContain("summary().cpu") + expect(panel).not.toContain("<IconCpu") + }) }) diff --git a/frontend/workspace/src/atlas/KernelPanel.tsx b/frontend/workspace/src/atlas/KernelPanel.tsx index f7f7e58c..e62889c6 100644 --- a/frontend/workspace/src/atlas/KernelPanel.tsx +++ b/frontend/workspace/src/atlas/KernelPanel.tsx @@ -1,12 +1,11 @@ import { For, Show, createMemo, createResource, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" -import { IconCpu } from "@/atlas/shared/Icon" import { identify } from "@/atlas/poll-identity" import { KernelCard, type KernelAction } from "@/atlas/KernelCard" import { CommandCard } from "@/atlas/CommandCard" import type { Job } from "@/atlas/ComputeJobsAPI" -import { RemoteJobCard, jobLive } from "@/atlas/RemoteJobCard" +import { RemoteJobCard, visibleJobs } from "@/atlas/RemoteJobCard" import { useKernelList } from "@/atlas/use-kernel-list" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -34,18 +33,26 @@ export function inventory<T>(request: Promise<T>, settled: (error: string) => vo ) } -const usage = (group: Group) => { +export const usage = (group: Group) => { const entries = [...group.kernels, ...group.commands] const memory = entries.reduce((total, entry) => total + (entry.resources?.memory_bytes ?? 0), 0) const cpu = entries.reduce((total, entry) => total + (entry.resources?.cpu_percent ?? 0), 0) / 100 - const kernels = `${group.kernels.length} ${group.kernels.length === 1 ? "kernel" : "kernels"}` - const commands = `${group.commands.length} ${group.commands.length === 1 ? "command" : "commands"}` - const jobs = `${group.jobs.length} ${group.jobs.length === 1 ? "job" : "jobs"}` - const ram = entries.some((entry) => entry.resources?.memory_bytes !== undefined) ? kernelMemoryLabel(memory) : "— rss" + const kinds = [ + group.kernels.length ? `${group.kernels.length} ${group.kernels.length === 1 ? "kernel" : "kernels"}` : undefined, + group.commands.length + ? `${group.commands.length} ${group.commands.length === 1 ? "command" : "commands"}` + : undefined, + group.jobs.length ? `${group.jobs.length} ${group.jobs.length === 1 ? "job" : "jobs"}` : undefined, + ] + .filter(Boolean) + .join(" · ") + const ram = entries.some((entry) => entry.resources?.memory_bytes !== undefined) + ? kernelMemoryLabel(memory) + : "Memory —" const cores = entries.some((entry) => entry.resources?.cpu_percent !== undefined) ? `${cpu.toFixed(1)} cores` - : "— cpu" - return `${kernels} · ${commands} · ${jobs} · ${ram} · ${cores}` + : "CPU —" + return { kinds, memory: ram, cpu: cores } } export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { @@ -107,7 +114,7 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { const value = group(command.sessionID) groups.set(command.sessionID, { ...value, commands: [...value.commands, command] }) } - for (const job of jobs().filter(jobLive)) { + for (const job of visibleJobs(jobs())) { const sessionID = job.session_id ?? projectJobs const value = group(sessionID) groups.set(sessionID, { ...value, jobs: [...value.jobs, job] }) @@ -191,7 +198,7 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { }) return ( - <section aria-label="Live project compute" data-testid="kernel-panel" class="kernel-panel"> + <section aria-label="Project compute" data-testid="kernel-panel" class="kernel-panel"> <div class="atlas-scroll kernel-panel__body"> <Show when={view.error || view.remote || view.problem}> <div role="alert" class="kernel-panel__message kernel-panel__message--error"> @@ -212,64 +219,74 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { when={groups().length > 0} fallback={ <div class="kernel-panel__empty"> - <span aria-hidden="true"> - <IconCpu size={15} strokeWidth={1.4} /> - </span> - <strong>{view.error ? "Compute inventory unavailable" : "No live compute"}</strong> + <strong>{view.error ? "Compute inventory unavailable" : "Runtimes start with your work"}</strong> <p> {view.error ? "The last poll could not read this project's kernels, commands, and remote jobs, so this is not a count of what is running." - : "Kernels, commands, and remote jobs appear here the moment any session starts computing in this project."} + : "Python and R kernels preserve session state automatically. Live commands and remote jobs appear here too."} </p> </div> } > <div class="kernel-panel__sessions"> <For each={groups()}> - {(sessionID) => ( - <section class="kernel-session" data-current={route() === sessionID ? "true" : undefined}> - <header class="kernel-session__header"> - <div class="kernel-session__identity"> - <span aria-hidden="true">›_</span> - <strong>{title(sessionID)}</strong> - <Show when={route() === sessionID}> - <em>current</em> - </Show> + {(sessionID) => { + const summary = () => usage(grouped().get(sessionID) ?? { kernels: [], commands: [], jobs: [] }) + return ( + <section + class="kernel-session" + aria-label={`${title(sessionID)} compute`} + data-current={route() === sessionID ? "true" : undefined} + > + <header class="kernel-session__header"> + <div class="kernel-session__identity"> + <strong>{title(sessionID)}</strong> + <Show when={route() === sessionID}> + <em>Current</em> + </Show> + </div> + <div + class="kernel-session__summary" + aria-label={`${summary().kinds}, ${summary().memory}, ${summary().cpu}`} + > + <span>{summary().kinds}</span> + <span>{summary().memory}</span> + <span>{summary().cpu}</span> + </div> + </header> + <div class="kernel-panel__list"> + <For each={grouped().get(sessionID)?.kernels ?? []}> + {(kernel) => ( + <KernelCard + kernel={kernel} + action={view.action} + onControl={(action) => void control(kernel, action)} + /> + )} + </For> + <For each={grouped().get(sessionID)?.commands ?? []}> + {(command) => ( + <CommandCard + command={command} + stopping={view.action === `${command.id}:stop`} + onStop={() => void stop(command)} + /> + )} + </For> + <For each={grouped().get(sessionID)?.jobs ?? []}> + {(job) => ( + <RemoteJobCard + job={job} + cancelling={view.action === `${job.id}:cancel`} + onCancel={() => cancel(job).then(() => undefined)} + onOutput={() => jobApi.log(job.id).then((value) => value.log)} + /> + )} + </For> </div> - <span>{usage(grouped().get(sessionID) ?? { kernels: [], commands: [], jobs: [] })}</span> - </header> - <div class="kernel-panel__list"> - <For each={grouped().get(sessionID)?.kernels ?? []}> - {(kernel) => ( - <KernelCard - kernel={kernel} - action={view.action} - onControl={(action) => void control(kernel, action)} - /> - )} - </For> - <For each={grouped().get(sessionID)?.commands ?? []}> - {(command) => ( - <CommandCard - command={command} - stopping={view.action === `${command.id}:stop`} - onStop={() => void stop(command)} - /> - )} - </For> - <For each={grouped().get(sessionID)?.jobs ?? []}> - {(job) => ( - <RemoteJobCard - job={job} - cancelling={view.action === `${job.id}:cancel`} - onCancel={() => cancel(job).then(() => undefined)} - onOutput={() => jobApi.log(job.id).then((value) => value.log)} - /> - )} - </For> - </div> - </section> - )} + </section> + ) + }} </For> </div> </Show> diff --git a/frontend/workspace/src/atlas/OpenScienceFileTree.tsx b/frontend/workspace/src/atlas/OpenScienceFileTree.tsx index 8d91c7dd..0c76e18f 100644 --- a/frontend/workspace/src/atlas/OpenScienceFileTree.tsx +++ b/frontend/workspace/src/atlas/OpenScienceFileTree.tsx @@ -224,7 +224,7 @@ export function OpenScienceFileTree(props: { onOpen?: (path: string) => void }): style={{ "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 500, + "font-weight": "var(--font-weight-medium)", color: "var(--color-text)", }} > diff --git a/frontend/workspace/src/atlas/ProjectRightPane.tsx b/frontend/workspace/src/atlas/ProjectRightPane.tsx new file mode 100644 index 00000000..e898fc1c --- /dev/null +++ b/frontend/workspace/src/atlas/ProjectRightPane.tsx @@ -0,0 +1,13 @@ +import { RightPane } from "@/atlas/RightPane" +import { SessionRenderProviders } from "@/pages/session-shell" + +/** Lazy project-route entry so the home screen does not pay for file, terminal, + * compute, and scientific renderers while the inspector still owns a lifetime + * above individual session routes. */ +export default function ProjectRightPane(props: { project: string; session: string }) { + return ( + <SessionRenderProviders> + <RightPane project={props.project} session={props.session} /> + </SessionRenderProviders> + ) +} diff --git a/frontend/workspace/src/atlas/ProjectWorkspaceFrame.css b/frontend/workspace/src/atlas/ProjectWorkspaceFrame.css new file mode 100644 index 00000000..704ed42e --- /dev/null +++ b/frontend/workspace/src/atlas/ProjectWorkspaceFrame.css @@ -0,0 +1,20 @@ +.project-workspace-frame { + width: 100%; + height: 100dvh; + min-width: 0; + min-height: 0; + display: flex; + overflow: hidden; + background: var(--color-bg); + color: var(--color-text); + isolation: isolate; +} + +.project-workspace-frame__route { + min-width: 0; + min-height: 0; + display: flex; + flex: 1 1 auto; + overflow: hidden; + background: var(--color-bg); +} diff --git a/frontend/workspace/src/atlas/ProjectWorkspaceFrame.tsx b/frontend/workspace/src/atlas/ProjectWorkspaceFrame.tsx new file mode 100644 index 00000000..501b37cc --- /dev/null +++ b/frontend/workspace/src/atlas/ProjectWorkspaceFrame.tsx @@ -0,0 +1,16 @@ +import type { JSX, ParentProps } from "solid-js" +import "./ProjectWorkspaceFrame.css" + +/** + * Project-owned split boundary. Conversation routes may change beneath the + * main slot, while the inspector sibling keeps one mounted owner for the life + * of the project route. + */ +export function ProjectWorkspaceFrame(props: ParentProps<{ inspector: JSX.Element }>): JSX.Element { + return ( + <div class="project-workspace-frame"> + <div class="project-workspace-frame__route">{props.children}</div> + {props.inspector} + </div> + ) +} diff --git a/frontend/workspace/src/atlas/RemoteJobCard.test.tsx b/frontend/workspace/src/atlas/RemoteJobCard.test.tsx index 53c6ad3f..f9ec0db2 100644 --- a/frontend/workspace/src/atlas/RemoteJobCard.test.tsx +++ b/frontend/workspace/src/atlas/RemoteJobCard.test.tsx @@ -73,8 +73,8 @@ test("completed Modal GPU work shows resources, delivered result, and released l expect(host.textContent).toContain("GPU") expect(host.textContent).toContain("Modal · A100 · 4 CPU · 16 GB") - expect(host.textContent).toContain("succeeded") - expect(host.textContent).toContain("exit 0 · 1 artifact · remote released") + expect(host.textContent).toContain("Succeeded") + expect(host.textContent).toContain("Exit 0 · 1 artifact · Remote released") expect(host.textContent).not.toContain("Cancel") host.querySelector<HTMLButtonElement>(".remote-job-card__actions button")!.click() await new Promise((resolve) => setTimeout(resolve, 0)) @@ -98,5 +98,28 @@ test("a live Modal job remains cancellable and counts as live", () => { ) expect(subject.jobLive(running)).toBe(true) + expect(subject.jobStatusLabel("interrupted")).toBe("Interrupted") expect(host.textContent).toContain("Cancel") }) + +test("keeps live work and a bounded newest-first set of completed results", () => { + const running = { + ...job, + id: "job_running", + status: "running" as const, + completed_at: undefined, + lifecycle: { ...job.lifecycle, execution: "running", resource: "active" as const }, + } + const older = { + ...job, + id: "job_older", + completed_at: "2026-08-08T09:01:01.000Z", + } + const newer = { + ...job, + id: "job_newer", + completed_at: "2026-08-08T11:01:01.000Z", + } + + expect(subject.visibleJobs([older, running, newer], 1).map((item) => item.id)).toEqual(["job_running", "job_newer"]) +}) diff --git a/frontend/workspace/src/atlas/RemoteJobCard.tsx b/frontend/workspace/src/atlas/RemoteJobCard.tsx index 7d096aea..2830c02c 100644 --- a/frontend/workspace/src/atlas/RemoteJobCard.tsx +++ b/frontend/workspace/src/atlas/RemoteJobCard.tsx @@ -2,6 +2,7 @@ import { Show, createSignal, onCleanup, type JSX } from "solid-js" import type { Job, Status } from "@/atlas/ComputeJobsAPI" const terminal = new Set<Status>(["succeeded", "failed", "cancelled", "interrupted"]) +const RECENT_COMPLETED_LIMIT = 8 export function jobLive(job: Job) { if (!terminal.has(job.status)) return true @@ -9,6 +10,19 @@ export function jobLive(job: Job) { return job.target.kind === "modal" && (resource === "starting" || resource === "active" || resource === "unknown") } +const jobTime = (job: Job) => Date.parse(job.completed_at ?? job.started_at ?? job.created_at) || 0 + +export function visibleJobs(jobs: Job[], completedLimit = RECENT_COMPLETED_LIMIT) { + const active: Job[] = [] + const completed: Job[] = [] + for (const job of jobs) { + if (jobLive(job)) active.push(job) + else completed.push(job) + } + completed.sort((a, b) => jobTime(b) - jobTime(a)) + return [...active, ...completed.slice(0, completedLimit)] +} + const elapsed = (job: Job, now: number) => { const start = Date.parse(job.started_at ?? job.created_at) const end = job.completed_at ? Date.parse(job.completed_at) : now @@ -25,16 +39,20 @@ const resource = (job: Job) => { const count = job.resources?.gpus && job.resources.gpus > 1 ? ` × ${job.resources.gpus}` : "" const cpu = job.resources?.cpus ? `${job.resources.cpus} CPU` : undefined const memory = job.resources?.memory_gb ? `${job.resources.memory_gb} GB` : undefined - return [gpu ? `${gpu}${count}` : undefined, cpu, memory].filter(Boolean).join(" · ") || "provider defaults" + return [gpu ? `${gpu}${count}` : undefined, cpu, memory].filter(Boolean).join(" · ") || "Provider defaults" +} + +export const jobStatusLabel = (status: Status) => { + return status.charAt(0).toUpperCase() + status.slice(1) } const result = (job: Job) => { const files = (job.artifacts?.length ?? 0) + (job.checkpoint ? 1 : 0) - const exit = job.exit_code === undefined || job.exit_code === null ? undefined : `exit ${job.exit_code}` + const exit = job.exit_code === undefined || job.exit_code === null ? undefined : `Exit ${job.exit_code}` const closed = job.target.kind === "modal" ? job.lifecycle?.resource === "closed" - ? "remote released" + ? "Remote released" : job.lifecycle?.resource : undefined return [exit, `${files} ${files === 1 ? "artifact" : "artifacts"}`, closed].filter(Boolean).join(" · ") @@ -74,25 +92,36 @@ export function RemoteJobCard(props: { <div class="kernel-card__copy"> <strong title={props.job.name}>{props.job.name}</strong> <span title={props.job.command}> - <i data-tone={jobLive(props.job) ? "active" : props.job.status === "succeeded" ? "ready" : "danger"} /> + <i + data-tone={jobLive(props.job) ? "active" : props.job.status === "succeeded" ? "ready" : "danger"} + aria-hidden="true" + /> {props.job.target_label} · {resource(props.job)} </span> </div> </div> - <span class="kernel-card__uptime" aria-label={`Runtime ${elapsed(props.job, now())}`}> - {elapsed(props.job, now())} - </span> - <span class="remote-job-card__result"> - <strong>{props.job.status}</strong> - <small>{result(props.job)}</small> - </span> + <div class="remote-job-card__summary" aria-label="Remote job status and runtime"> + <span class="kernel-card__uptime kernel-card__metric" aria-label={`Runtime ${elapsed(props.job, now())}`}> + <strong>{elapsed(props.job, now())}</strong> + <small>Runtime</small> + </span> + <span class="remote-job-card__result"> + <strong>{jobStatusLabel(props.job.status)}</strong> + <small>{result(props.job)}</small> + </span> + </div> <div class="remote-job-card__actions"> - <button type="button" onClick={read} aria-expanded={output() !== undefined}> + <button type="button" onClick={read} aria-expanded={output() !== undefined} aria-busy={loading()}> {loading() ? "Loading…" : output() === undefined ? "Output" : "Hide"} </button> <Show when={jobLive(props.job)}> - <button type="button" disabled={props.cancelling} onClick={() => void props.onCancel()}> + <button + type="button" + disabled={props.cancelling} + aria-busy={props.cancelling} + onClick={() => void props.onCancel()} + > {props.cancelling ? "Cancelling…" : "Cancel"} </button> </Show> diff --git a/frontend/workspace/src/atlas/RightPane.tsx b/frontend/workspace/src/atlas/RightPane.tsx index 8c91d11c..88404cbc 100644 --- a/frontend/workspace/src/atlas/RightPane.tsx +++ b/frontend/workspace/src/atlas/RightPane.tsx @@ -21,22 +21,27 @@ import { FileView } from "@/atlas/FilePreview" import { TerminalSurface } from "@/atlas/TerminalSurface" import { SessionTraceSurface } from "@/atlas/SessionTraceSurface" import { artifactContext } from "@/artifacts/context" +import { useDialog } from "@synsci/ui/context/dialog" import { ArtifactInspector } from "@/artifacts/ArtifactInspector" import { StoredArtifactView } from "@/artifacts/StoredArtifactView" +import { confirmDialog } from "@/atlas/dialogs" +import { discardFileDraft } from "@/atlas/file-drafts" import { AsciiSpinner } from "@/atlas/shared/AsciiSpinner" -import { IconChevronLeft, IconCollapse, IconExpand, IconX } from "@/atlas/shared/Icon" +import { IconChevronLeft, IconCollapse, IconExpand, IconSplit, IconX } from "@/atlas/shared/Icon" import { DEFAULT_PANE_WIDTH, - MAX_PANE_WIDTH, MIN_PANE_WIDTH, INLINE_PANE_BREAKPOINT, clampPaneWidth, + equalPaneWidth, legacyPaneWidthKey, - paneWidthForViewport, + maxPaneWidthForWorkspace, + paneWidthForWorkspace, paneWidthKey, readPaneWidth, savePaneWidth, } from "@/atlas/right-pane-layout" +import "./right-pane-tabs.css" const RESIZE_STEP = 16 const labels: Record<ContextTab, string> = { @@ -50,7 +55,14 @@ const labels: Record<ContextTab, string> = { export function RightPaneGate(props: { children: JSX.Element }): JSX.Element { createEffect(() => uiStore.syncArtifact(Boolean(artifactContext.active()))) - return <Show when={uiStore.rightPaneOpen()}>{props.children}</Show> + const retained = () => uiStore.workTabs().some((tab) => tab.kind === "file") + return ( + <Show when={uiStore.rightPaneOpen() || retained()}> + <div class="right-pane-gate" data-open={uiStore.rightPaneOpen() ? "true" : "false"}> + {props.children} + </div> + </Show> + ) } const focusable = @@ -63,6 +75,7 @@ export function RightPaneFrame(props: { expanded?: boolean width: number onClose: () => void + onPane?: (element: HTMLElement) => void children: JSX.Element }): JSX.Element { const refs: { @@ -109,7 +122,7 @@ export function RightPaneFrame(props: { } if (event.key !== "Tab" || !refs.pane) return const items = Array.from(refs.pane.querySelectorAll<HTMLElement>(focusable)).filter( - (item) => item.getAttribute("aria-hidden") !== "true", + (item) => !item.closest('[hidden], [aria-hidden="true"], [inert]'), ) if (!items.length) { event.preventDefault() @@ -140,18 +153,18 @@ export function RightPaneFrame(props: { tabindex={-1} onClick={props.onClose} style={{ - all: "unset", position: "fixed", inset: 0, "z-index": 69, cursor: "default", - background: "color-mix(in srgb, var(--color-bg) 62%, transparent)", - "backdrop-filter": "blur(1px)", }} /> </Show> <aside - ref={(element) => (refs.pane = element)} + ref={(element) => { + refs.pane = element + props.onPane?.(element) + }} class="session-right-pane" aria-label="Research inspector" role={props.modal ? "dialog" : undefined} @@ -215,12 +228,62 @@ export function RightPane( } const [width, setWidth] = createSignal(initial()) const [expanded, setExpanded] = createSignal(false) - const [viewport, setViewport] = createSignal(typeof window === "undefined" ? 1440 : window.innerWidth) + const [dirtyFiles, setDirtyFiles] = createSignal<string[]>([]) + const dialog = useDialog() + const [workspace, setWorkspace] = createSignal(typeof window === "undefined" ? 1200 : window.innerWidth) const [narrow, setNarrow] = createSignal(typeof window !== "undefined" && window.innerWidth < INLINE_PANE_BREAKPOINT) const [seen, setSeen] = createSignal(context() === "files") - const paneWidth = createMemo(() => paneWidthForViewport(width(), viewport())) + const limit = createMemo(() => maxPaneWidthForWorkspace(workspace())) + const paneWidth = createMemo(() => paneWidthForWorkspace(width(), workspace())) const drag = { start: null as { x: number; width: number } | null } + const frame: { observer?: ResizeObserver; pane?: HTMLElement } = {} const browser = () => context() === "files" && !uiStore.file() && !uiStore.saved() + const fileTabs = createMemo(() => + uiStore.workTabs().filter((tab): tab is Extract<WorkTab, { kind: "file" }> => tab.kind === "file"), + ) + const selectedFile = (tab: Extract<WorkTab, { kind: "file" }>) => { + const current = uiStore.file() + return current?.directory === tab.file.directory && current.path === tab.file.path + } + const visibleFile = (tab: Extract<WorkTab, { kind: "file" }>) => + context() === "files" && uiStore.activeWorkTab() === tab.id + const markDirty = (id: string, dirty: boolean) => + setDirtyFiles((items) => (dirty ? [...new Set([...items, id])] : items.filter((item) => item !== id))) + const openDirty = () => dirtyFiles().filter((id) => fileTabs().some((tab) => tab.id === id)) + const closeWorkTab = async (id?: string) => { + const target = uiStore.workTabs().find((tab) => tab.id === (id ?? uiStore.activeWorkTab())) + if (target?.kind === "file" && openDirty().includes(target.id)) { + const confirmed = await confirmDialog(dialog, { + title: "Discard unsaved changes?", + message: `${target.file.name} has changes that have not been saved.`, + confirmLabel: "Discard and close", + danger: true, + }) + if (!confirmed) return + markDirty(target.id, false) + } + if (target?.kind === "file") discardFileDraft(target.file.directory, target.file.path) + uiStore.closeWorkTab(id) + } + const closePane = async () => { + const pending = openDirty() + if (pending.length > 0) { + const confirmed = await confirmDialog(dialog, { + title: "Close with unsaved changes?", + message: `${pending.length} ${pending.length === 1 ? "file has" : "files have"} changes that have not been saved.`, + confirmLabel: "Discard and close", + danger: true, + }) + if (!confirmed) return + setDirtyFiles([]) + } + for (const id of pending) { + const tab = fileTabs().find((item) => item.id === id) + if (tab) discardFileDraft(tab.file.directory, tab.file.path) + uiStore.closeWorkTab(id) + } + uiStore.closeContext() + } createEffect(on(key, () => setWidth(initial()))) createEffect(() => { @@ -229,13 +292,34 @@ export function RightPane( onMount(() => { const resize = () => { - setViewport(window.innerWidth) setNarrow(window.innerWidth < INLINE_PANE_BREAKPOINT) + if (!frame.pane?.parentElement) setWorkspace(window.innerWidth) } window.addEventListener("resize", resize) - onCleanup(() => window.removeEventListener("resize", resize)) + onCleanup(() => { + window.removeEventListener("resize", resize) + frame.observer?.disconnect() + }) }) + const observePane = (element: HTMLElement) => { + frame.observer?.disconnect() + frame.pane = element + const parent = element.parentElement + if (!parent) return + const main = element.previousElementSibling + const measure = () => { + const center = main instanceof HTMLElement ? main.clientWidth + element.clientWidth : 0 + setWorkspace(center || parent.clientWidth || window.innerWidth) + } + measure() + if (typeof ResizeObserver === "undefined") return + frame.observer = new ResizeObserver(measure) + frame.observer.observe(parent) + frame.observer.observe(element) + if (main instanceof HTMLElement) frame.observer.observe(main) + } + const onHandlePointerDown = (event: PointerEvent) => { drag.start = { x: event.clientX, width: paneWidth() } ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) @@ -244,7 +328,7 @@ export function RightPane( } const onHandlePointerMove = (event: PointerEvent) => { if (!drag.start) return - const next = clampPaneWidth(drag.start.width + (drag.start.x - event.clientX)) + const next = clampPaneWidth(drag.start.width + (drag.start.x - event.clientX), limit()) setWidth(next) } const onHandlePointerUp = (event: PointerEvent) => { @@ -260,7 +344,14 @@ export function RightPane( const delta = event.key === "ArrowLeft" ? RESIZE_STEP : event.key === "ArrowRight" ? -RESIZE_STEP : 0 if (!delta) return event.preventDefault() - const next = clampPaneWidth(paneWidth() + delta) + const next = clampPaneWidth(paneWidth() + delta, limit()) + setWidth(next) + try { + savePaneWidth(key(), next) + } catch {} + } + const splitEvenly = () => { + const next = equalPaneWidth(workspace()) setWidth(next) try { savePaneWidth(key(), next) @@ -270,12 +361,13 @@ export function RightPane( return ( <RightPaneGate> <RightPaneFrame - modal={narrow() || expanded()} - mobile={narrow()} + modal={uiStore.rightPaneOpen() && (narrow() || expanded())} + mobile={uiStore.rightPaneOpen() && narrow()} stacked={false} expanded={expanded()} width={paneWidth()} - onClose={() => (expanded() ? setExpanded(false) : uiStore.closeContext())} + onClose={() => (expanded() ? setExpanded(false) : void closePane())} + onPane={observePane} > <> <div @@ -283,7 +375,7 @@ export function RightPane( aria-orientation="vertical" aria-label="Resize research inspector" aria-valuemin={MIN_PANE_WIDTH} - aria-valuemax={MAX_PANE_WIDTH} + aria-valuemax={limit()} aria-valuenow={paneWidth()} tabindex={narrow() ? -1 : 0} onKeyDown={onHandleKeyDown} @@ -291,20 +383,10 @@ export function RightPane( on:pointermove={onHandlePointerMove} on:pointerup={onHandlePointerUp} on:pointercancel={onHandlePointerUp} + onDblClick={splitEvenly} aria-hidden={narrow() ? "true" : undefined} - hidden={expanded()} - style={{ - position: "absolute", - left: "-3px", - top: 0, - width: "6px", - height: "100%", - cursor: "ew-resize", - "z-index": 5, - "touch-action": "none", - }} - onMouseEnter={(event) => (event.currentTarget.style.background = "var(--color-accent-subtle)")} - onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")} + hidden={narrow() || expanded()} + class="research-inspector__resize" /> <div class="research-inspector__header"> <Show @@ -319,11 +401,22 @@ export function RightPane( tabs={uiStore.workTabs()} active={uiStore.activeWorkTab()} onSelect={uiStore.activateWorkTab} - onClose={uiStore.closeWorkTab} + onClose={(id) => void closeWorkTab(id)} onReorder={uiStore.moveWorkTab} /> </Show> <div class="research-inspector__controls"> + <Show when={!narrow() && !expanded()}> + <button + type="button" + class="research-inspector__control" + onClick={splitEvenly} + title="Split workspace evenly" + aria-label="Split workspace evenly" + > + <IconSplit size={16} strokeWidth={1.45} /> + </button> + </Show> <Show when={!narrow()}> <button type="button" @@ -340,7 +433,7 @@ export function RightPane( <button type="button" class="research-inspector__control" - onClick={() => (narrow() ? uiStore.closeContext() : uiStore.closeWorkTab())} + onClick={() => (narrow() ? void closePane() : void closeWorkTab())} title={narrow() ? "Back to conversation" : "Close context"} aria-label={narrow() ? "Back to conversation" : "Close context"} data-modal-initial-focus @@ -352,39 +445,41 @@ export function RightPane( </div> </div> <Suspense fallback={<InspectorLoading label={labels[context()]} />}> - <Show when={uiStore.file()} keyed> - {(file) => ( + <For each={fileTabs()}> + {(tab) => ( <div - aria-hidden={context() === "files" ? undefined : "true"} + aria-hidden={visibleFile(tab) ? undefined : "true"} + hidden={!visibleFile(tab)} style={{ flex: 1, "min-height": 0, "min-width": 0, - display: context() === "files" ? "flex" : "none", + display: visibleFile(tab) ? "flex" : "none", "flex-direction": "column", }} > <Show - when={!file.external} + when={!tab.file.external} fallback={ <ExternalFileAccess - file={file} - active={context() === "files" || context() === "artifact"} - onClose={() => uiStore.closeFile()} + file={tab.file} + active={selectedFile(tab) && (context() === "files" || context() === "artifact")} + onClose={() => void closeWorkTab(tab.id)} /> } > <FileView - directory={file.directory} - path={file.path} + directory={tab.file.directory} + path={tab.file.path} subtitle="Session files" - active={context() === "files" || context() === "artifact"} - onClose={() => uiStore.closeFile()} + active={selectedFile(tab) && (context() === "files" || context() === "artifact")} + onDirtyChange={(dirty) => markDirty(tab.id, dirty)} + onClose={() => void closeWorkTab(tab.id)} /> </Show> </div> )} - </Show> + </For> <Show when={seen()}> <div data-component="files-context" @@ -443,51 +538,72 @@ function WorkTabStrip(props: { onReorder: (id: string, to: number) => void }): JSX.Element { return ( - <nav class="inspector-tabs" aria-label="Contextual work tabs"> + <nav class="inspector-tabs" aria-label="Contextual work tabs" role="tablist" aria-orientation="horizontal"> <For each={props.tabs}> {(tab, index) => ( <div - class="inspector-tab" - role="tab" - tabindex={0} + class="inspector-tab-pair" title={tab.kind === "file" ? tab.file.path : tab.kind === "saved" ? tab.artifact.title : workTabLabel(tab)} - aria-selected={props.active === tab.id} data-active={props.active === tab.id ? "true" : undefined} - draggable="true" - onDragStart={(event) => { - event.dataTransfer?.setData(WORK_TAB_DRAG, tab.id) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" - }} - onDragOver={(event) => { - if (event.dataTransfer?.types.includes(WORK_TAB_DRAG)) event.preventDefault() - }} - onDrop={(event) => { - const dragged = event.dataTransfer?.getData(WORK_TAB_DRAG) - if (!dragged || dragged === tab.id) return - event.preventDefault() - props.onReorder(dragged, index()) - }} - onClick={() => props.onSelect(tab.id)} - onKeyDown={(event) => { - if (event.altKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { - event.preventDefault() - props.onReorder(tab.id, index() + (event.key === "ArrowRight" ? 1 : -1)) - return - } - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - props.onSelect(tab.id) - }} + role="presentation" > - <span class="inspector-tab__name">{workTabLabel(tab)}</span> + <button + type="button" + class="inspector-tab" + role="tab" + data-work-tab={tab.id} + tabindex={props.active === tab.id ? 0 : -1} + aria-selected={props.active === tab.id} + data-active={props.active === tab.id ? "true" : undefined} + draggable="true" + onDragStart={(event) => { + event.dataTransfer?.setData(WORK_TAB_DRAG, tab.id) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" + }} + onDragOver={(event) => { + if (event.dataTransfer?.types.includes(WORK_TAB_DRAG)) event.preventDefault() + }} + onDrop={(event) => { + const dragged = event.dataTransfer?.getData(WORK_TAB_DRAG) + if (!dragged || dragged === tab.id) return + event.preventDefault() + props.onReorder(dragged, index()) + }} + onClick={() => props.onSelect(tab.id)} + onKeyDown={(event) => { + if (event.altKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { + event.preventDefault() + props.onReorder(tab.id, index() + (event.key === "ArrowRight" ? 1 : -1)) + return + } + const target = + event.key === "Home" + ? props.tabs[0] + : event.key === "End" + ? props.tabs.at(-1) + : event.key === "ArrowLeft" + ? props.tabs[(index() - 1 + props.tabs.length) % props.tabs.length] + : event.key === "ArrowRight" + ? props.tabs[(index() + 1) % props.tabs.length] + : undefined + if (!target) return + event.preventDefault() + const strip = event.currentTarget.closest(".inspector-tabs") + props.onSelect(target.id) + queueMicrotask(() => + Array.from(strip?.querySelectorAll<HTMLElement>("[data-work-tab]") ?? []) + .find((item) => item.dataset.workTab === target.id) + ?.focus(), + ) + }} + > + <span class="inspector-tab__name">{workTabLabel(tab)}</span> + </button> <button type="button" class="inspector-tab__close" aria-label={`Close ${workTabLabel(tab)}`} - onClick={(event) => { - event.stopPropagation() - props.onClose(tab.id) - }} + onClick={() => props.onClose(tab.id)} > <IconX size={11} strokeWidth={1.5} /> </button> @@ -504,7 +620,7 @@ function InspectorLoading(props: { label: string }): JSX.Element { data-component="inspector-loading" style={{ flex: 1, display: "flex", "align-items": "center", "justify-content": "center" }} > - <AsciiSpinner size={10} label={`loading ${props.label.toLowerCase()}…`} color="var(--color-text-faint)" /> + <AsciiSpinner size={10} label={`Loading ${props.label.toLowerCase()}…`} color="var(--color-text-faint)" /> </div> ) } diff --git a/frontend/workspace/src/atlas/SessionTraceSurface.css b/frontend/workspace/src/atlas/SessionTraceSurface.css index 962f368d..ac9cc42b 100644 --- a/frontend/workspace/src/atlas/SessionTraceSurface.css +++ b/frontend/workspace/src/atlas/SessionTraceSurface.css @@ -13,8 +13,7 @@ display: flex; align-items: flex-start; gap: 12px; - padding: 2px 0 13px; - border-bottom: 1px solid var(--color-border); + padding: 2px 0 16px; } .session-trace__intro > div { @@ -25,9 +24,9 @@ .session-trace__eyebrow { display: block; margin-bottom: 6px; - font-size: 10px; - font-weight: 500; - letter-spacing: 0.035em; + font-size: 11px; + font-weight: var(--font-weight-medium); + letter-spacing: var(--letter-spacing-normal); color: var(--color-text-faint); } @@ -35,7 +34,7 @@ margin: 0; overflow: hidden; font-size: 16px; - font-weight: 600; + font-weight: var(--font-weight-emphasis); line-height: 1.25; letter-spacing: -0.015em; text-overflow: ellipsis; @@ -45,23 +44,23 @@ .session-trace__intro p { max-width: 52ch; margin: 5px 0 0; - font-size: 11px; + font-size: 12px; line-height: 1.55; color: var(--color-text-muted); text-wrap: pretty; } .session-trace__refresh { - width: 28px; - height: 28px; + width: 32px; + height: 32px; flex: 0 0 auto; display: grid; place-items: center; padding: 0; color: var(--color-text-faint); background: transparent; - border: 1px solid var(--color-border); - border-radius: 7px; + border: 0; + border-radius: var(--atlas-radius-xs); transition: color 160ms ease, background 160ms ease, @@ -87,24 +86,24 @@ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; - padding: 12px 0; - border-bottom: 1px solid var(--color-border); + gap: 8px; + padding: 8px; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); } .session-trace__metrics > div { min-width: 0; - padding: 0 12px; - border-left: 1px solid var(--color-border); + padding: 5px 8px; } .session-trace__metrics > div:first-child { - padding-left: 0; - border-left: 0; + padding-left: 8px; } .session-trace__metrics dt, .session-trace__counts dt { - font-size: 9.5px; + font-size: 10.5px; color: var(--color-text-faint); } @@ -113,7 +112,7 @@ overflow: hidden; font-family: var(--font-family-mono); font-size: 15px; - font-weight: 500; + font-weight: var(--font-weight-medium); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; @@ -122,7 +121,7 @@ .session-trace__metrics span { display: block; overflow: hidden; - font-size: 9.5px; + font-size: 10px; color: var(--color-text-faint); text-overflow: ellipsis; white-space: nowrap; @@ -132,14 +131,17 @@ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; - padding: 9px 0; - border-bottom: 1px solid var(--color-border); + gap: 6px; + margin-top: 10px; + padding: 8px; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); } .session-trace__counts > div { position: relative; min-width: 0; - padding: 3px 6px; + padding: 4px 6px; } .session-trace__counts dd { @@ -147,14 +149,14 @@ margin: 0 0 0 6px; font-family: var(--font-family-mono); font-size: 11px; - font-weight: 550; + font-weight: var(--font-weight-emphasis); font-variant-numeric: tabular-nums; } .session-trace__counts span { display: block; margin-top: 2px; - font-size: 8.5px; + font-size: 10px; color: var(--color-text-faint); } @@ -167,13 +169,15 @@ grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 3px 10px; - padding: 10px 0; - border-bottom: 1px solid var(--color-border); + margin-top: 10px; + padding: 10px 12px; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); } .session-trace__route > span { grid-row: 1 / 3; - font-size: 9.5px; + font-size: 10.5px; color: var(--color-text-faint); } @@ -186,16 +190,16 @@ .session-trace__route strong { font-size: 11px; - font-weight: 550; + font-weight: var(--font-weight-emphasis); } .session-trace__route small { - font-size: 9.5px; + font-size: 10.5px; color: var(--color-text-faint); } .session-trace__activity { - padding-top: 14px; + padding-top: 18px; } .session-trace__section-title { @@ -207,13 +211,13 @@ .session-trace__section-title h3 { margin: 0; - font-size: 11px; - font-weight: 600; + font-size: 12px; + font-weight: var(--font-weight-emphasis); } .session-trace__section-title > span { font-family: var(--font-family-mono); - font-size: 9.5px; + font-size: 10.5px; color: var(--color-text-faint); } @@ -249,7 +253,7 @@ .session-trace__activity time { padding-top: 1px; font-family: var(--font-family-mono); - font-size: 9px; + font-size: 10px; font-variant-numeric: tabular-nums; color: var(--color-text-faint); } @@ -288,14 +292,14 @@ } .session-trace__activity strong { - font-size: 10.5px; - font-weight: 550; + font-size: 11.5px; + font-weight: var(--font-weight-emphasis); line-height: 1.35; } .session-trace__activity li > div > span { margin-top: 3px; - font-size: 9.5px; + font-size: 10.5px; line-height: 1.4; color: var(--color-text-faint); } @@ -312,9 +316,8 @@ flex-wrap: wrap; gap: 5px 11px; margin-top: 10px; - padding-top: 13px; - border-top: 1px solid var(--color-border); - font-size: 9px; + padding-top: 6px; + font-size: 10px; color: var(--color-text-faint); } @@ -343,7 +346,7 @@ .session-trace__state strong { font-size: 12px; - font-weight: 550; + font-weight: var(--font-weight-emphasis); } .session-trace__state p { @@ -362,7 +365,7 @@ color: var(--color-text); background: var(--color-bg-subtle); border: 1px solid var(--color-border); - border-radius: 7px; + border-radius: var(--atlas-radius-xs); } .session-trace__skeleton { @@ -374,23 +377,18 @@ .session-trace__skeleton span { width: min(100%, 360px); height: 52px; - border-radius: 7px; - background: linear-gradient( - 90deg, - var(--color-bg-subtle), - color-mix(in srgb, var(--color-bg-subtle) 68%, var(--color-bg)), - var(--color-bg-subtle) - ); - background-size: 220% 100%; + border-radius: var(--atlas-radius-xs); + background: var(--color-bg-subtle); animation: session-trace-pulse 1.4s ease-in-out infinite; } @keyframes session-trace-pulse { - from { - background-position: 100% 0; + 0%, + 100% { + opacity: 0.58; } - to { - background-position: -100% 0; + 50% { + opacity: 1; } } @@ -414,3 +412,17 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +@media (prefers-reduced-motion: reduce) { + .session-trace__refresh { + transition: none; + } + + .session-trace__refresh:active { + transform: none; + } + + .session-trace__skeleton span { + animation: none; + } +} diff --git a/frontend/workspace/src/atlas/SkillsBrowser.tsx b/frontend/workspace/src/atlas/SkillsBrowser.tsx index c34c1798..5bef76e8 100644 --- a/frontend/workspace/src/atlas/SkillsBrowser.tsx +++ b/frontend/workspace/src/atlas/SkillsBrowser.tsx @@ -28,6 +28,12 @@ function originOf(skill: SkillRow): string { return "default" } +function sentence(value: string) { + const text = value.trim() + if (!text) return text + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}` +} + export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: () => void }): JSX.Element { const sync = useSync() const [query, setQuery] = createSignal("") @@ -108,12 +114,11 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: style={{ "font-family": FONT_MONO, "font-size": "10px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", + "letter-spacing": "normal", color: "var(--color-text-faint)", }} > - skills + Skills </span> <span class="tab-fig" @@ -125,7 +130,7 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: autofocus value={query()} onInput={(e) => setQuery(e.currentTarget.value)} - placeholder="search skills…" + placeholder="Search skills…" style={{ all: "unset", flex: 1, @@ -138,7 +143,8 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: <button type="button" onClick={props.onClose} - title="close (esc)" + aria-label="Close skills" + title="Close (Esc)" style={{ all: "unset", cursor: "pointer", @@ -165,7 +171,7 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: color: "var(--color-text-faint)", }} > - no matching skills + No matching skills </div> } > @@ -177,12 +183,11 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: padding: "6px 8px 3px", "font-family": FONT_MONO, "font-size": "10px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", + "letter-spacing": "normal", color: "var(--color-text-faint)", }} > - {group.label} + {sentence(group.label)} </div> <For each={group.items}> {(skill) => ( @@ -208,7 +213,7 @@ export function SkillsBrowser(props: { onPick: (name: string) => void; onClose: style={{ "font-family": FONT_SANS, "font-size": "13px", - "font-weight": 500, + "font-weight": "var(--font-weight-medium)", color: "var(--color-text)", }} > @@ -332,7 +337,7 @@ export function SkillLibraryDialog(props: { onPick: (name: string) => void }): J autofocus value={query()} onInput={(e) => setQuery(e.currentTarget.value)} - placeholder="search skills…" + placeholder="Search skills…" style={{ all: "unset", flex: 1, @@ -346,12 +351,11 @@ export function SkillLibraryDialog(props: { onPick: (name: string) => void }): J style={{ "font-family": FONT_MONO, "font-size": "11px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", + "letter-spacing": "normal", color: "var(--color-text-faint)", }} > - {total()} skills + Skills: {total()} </span> </div> @@ -379,7 +383,7 @@ export function SkillLibraryDialog(props: { onPick: (name: string) => void }): J color: "var(--color-text-faint)", }} > - no matching skills + No matching skills </div> } > @@ -396,12 +400,11 @@ export function SkillLibraryDialog(props: { onPick: (name: string) => void }): J "margin-bottom": "4px", "font-family": FONT_MONO, "font-size": "11px", - "letter-spacing": "0.08em", - "text-transform": "uppercase", + "letter-spacing": "normal", color: "var(--color-text-faint)", }} > - <span style={{ flex: 1 }}>{group.label}</span> + <span style={{ flex: 1 }}>{sentence(group.label)}</span> <span>{group.items.length}</span> </div> <For each={group.items}> @@ -435,7 +438,7 @@ export function SkillLibraryDialog(props: { onPick: (name: string) => void }): J style={{ "font-family": FONT_MONO, "font-size": "14px", - "font-weight": 500, + "font-weight": "var(--font-weight-medium)", color: "var(--color-text)", }} > diff --git a/frontend/workspace/src/atlas/SkillsPage.tsx b/frontend/workspace/src/atlas/SkillsPage.tsx index 819d5624..1de57e4e 100644 --- a/frontend/workspace/src/atlas/SkillsPage.tsx +++ b/frontend/workspace/src/atlas/SkillsPage.tsx @@ -1,26 +1,30 @@ // Skills — the reusable catalog of expert playbooks agents load on demand. // Data + enable/disable + add flows use the real app.skills / app.skill.write / -// permission.skill APIs. The embedded presentation fits the Customize frame -// without adding a second page title or center-workspace chrome. -import { For, Show, createMemo, createResource, createSignal, type JSX } from "solid-js" +// permission.skill APIs. The embedded presentation fits the Settings frame +// while preserving a useful catalog heading and clear controls. +import { + For, + Show, + createEffect, + createMemo, + createResource, + createSignal, + onCleanup, + onMount, + type JSX, +} from "solid-js" import { Switch } from "@synsci/ui/switch" import { Icon } from "@synsci/ui/icon" +import type { IconProps } from "@synsci/ui/icon" import { showToast } from "@synsci/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" import { useGlobalSync } from "@/context/global-sync" -import { FONT_SANS } from "@/styles/tokens" import type { Config } from "@synsci/sdk/v2/client" import { installFromGit } from "./skills-settings" -import { - SearchInput, - FilterMenu, - AddMenu, - Toolbar, - EmptyState, - FormField, - FormButton, -} from "@/components/settings/_shared" +import { restoreExactSkillPermission, skillAction, skillPermissionChange } from "./skill-permissions" +import "./skills-page.css" +import { SearchInput, FilterMenu, AddMenu, EmptyState, FormField, FormButton } from "@/components/settings/_shared" interface Skill { name: string @@ -32,7 +36,48 @@ interface Skill { entry?: boolean } -type Action = "allow" | "deny" +const SKILL_CACHE_KEY = "openscience.skills.catalog.v1" +const INITIAL_SKILL_ROWS = 56 +const SKILL_ROW_BATCH = 56 +let memorySkillCache: Skill[] | undefined +let skillCatalogRequest: Promise<Skill[]> | undefined + +function cachedSkills() { + if (memorySkillCache) return memorySkillCache + if (typeof sessionStorage === "undefined") return [] + try { + const parsed = JSON.parse(sessionStorage.getItem(SKILL_CACHE_KEY) ?? "null") as { skills?: Skill[] } | null + if (!Array.isArray(parsed?.skills)) return [] + memorySkillCache = parsed.skills + return parsed.skills + } catch { + return [] + } +} + +function rememberSkills(skills: Skill[]) { + memorySkillCache = skills + if (typeof sessionStorage === "undefined") return + try { + sessionStorage.setItem(SKILL_CACHE_KEY, JSON.stringify({ skills })) + } catch { + // The in-memory cache still makes later Settings visits immediate. + } +} + +function loadSkillCatalog(load: () => Promise<Skill[]>) { + if (skillCatalogRequest) return skillCatalogRequest + skillCatalogRequest = load() + .then((skills) => { + rememberSkills(skills) + return skills + }) + .finally(() => { + skillCatalogRequest = undefined + }) + return skillCatalogRequest +} + type View = "list" | "scratch" | "github" type Source = "default" | "learned" | "installed" | "user" | "project" type SourceView = "all" | Source @@ -47,12 +92,63 @@ function sourceOf(skill: Skill): Source { return "default" } -const SOURCE_DOT: Record<Source, string> = { - default: "var(--color-text-faint)", - learned: "var(--color-success, #3fb950)", - installed: "var(--color-text-interactive-base, var(--color-text))", - user: "var(--color-warning, #d29922)", - project: "var(--color-info, #58a6ff)", +type SkillIdentity = Pick<Skill, "name" | "description" | "category" | "tags"> + +const CATEGORY_ICON: Record<string, IconProps["name"]> = { + biology: "activity", + chemistry: "flask", + physics: "atom", + quantum: "sparkles", + "ml-training": "cpu", + "ml-inference": "models", + databases: "server", + "llm-tools": "brain", + coding: "code", + writing: "pencil-line", + research: "magnifying-glass", + "data-engineering": "braces", + "cloud-compute": "cloud", + visualization: "layout-grid", +} + +const SKILL_ICON_RULES: Array<{ terms: string[]; icon: IconProps["name"] }> = [ + { terms: ["microscopy", "bioimage", "imaging", "vision", "image"], icon: "photo" }, + { terms: ["clinical", "decision support", "health", "medical"], icon: "checklist" }, + { terms: ["genomic", "genome", "sequence", "biopython", "protein", "gene"], icon: "braces" }, + { terms: ["literature", "citation", "paper", "publication"], icon: "book-open" }, + { terms: ["plot", "chart"], icon: "layout-grid" }, + { terms: ["database", "sql", "registry", "warehouse"], icon: "server" }, + { terms: ["security", "safety", "permission", "audit"], icon: "shield" }, + { terms: ["benchmark", "evaluation", "test", "review"], icon: "checklist" }, + { terms: ["github", "git", "repository"], icon: "github" }, + { terms: ["web", "browser", "scrape", "crawl"], icon: "window-cursor" }, + { terms: ["notebook", "python", "r-language", "shell", "script"], icon: "console" }, + { terms: ["presentation", "slide", "poster"], icon: "layout-grid" }, +] + +const FALLBACK_ICONS: IconProps["name"][] = ["book-open", "task", "code-lines", "flask", "models", "folder-tree"] + +/** + * Prefer a specific subject icon, then the declared category, and finally a + * stable name-derived fallback. The catalog therefore remains scannable even + * when third-party skills omit optional metadata. + */ +export function skillIconFor(skill: SkillIdentity): IconProps["name"] { + const category = skill.category?.trim().toLowerCase() + const signature = [skill.name, skill.description, ...(skill.tags ?? [])].filter(Boolean).join(" ").toLowerCase() + const specific = SKILL_ICON_RULES.find((rule) => rule.terms.some((term) => signature.includes(term))) + if (specific) return specific.icon + if (category && CATEGORY_ICON[category]) return CATEGORY_ICON[category] + + let hash = 0 + for (const char of category || skill.name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + return FALLBACK_ICONS[hash % FALLBACK_ICONS.length]! +} + +function displayLabel(value: string) { + const words = value.replace(/[-_]+/g, " ").trim() + const label = /[A-Z]/.test(words) && words === words.toUpperCase() ? words.toLowerCase() : words + return label ? label[0]!.toUpperCase() + label.slice(1) : value } const SOURCE_LABEL: Record<Source, string> = { @@ -68,43 +164,72 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { const platform = usePlatform() const sync = useGlobalSync() - const [skills, skillsCtl] = createResource(async () => { - const res = await sdk.client.app.skills() - return (res.data ?? []) as Skill[] - }) + const initialSkills = cachedSkills() + const [skills, skillsCtl] = createResource( + () => + loadSkillCatalog(async () => { + const res = await sdk.client.app.skills() + return (res.data ?? []) as Skill[] + }), + { initialValue: initialSkills }, + ) const [search, setSearch] = createSignal("") const [category, setCategory] = createSignal("all") const [source, setSource] = createSignal<SourceView>("all") const [view, setView] = createSignal<View>("list") const [busy, setBusy] = createSignal(false) + const [visibleRows, setVisibleRows] = createSignal(INITIAL_SKILL_ROWS) + const [permissionPending, setPermissionPending] = createSignal<Record<string, number>>({}) + const permissionVersions = new Map<string, number>() + let permissionWrites = Promise.resolve() + let workspaceElement: HTMLDivElement | undefined let fileInput: HTMLInputElement | undefined // Enable/disable is the real `permission.skill` config: a skill an agent can // load is one whose skill-permission isn't "deny" (the skill tool filters the // rest), so this toggle is effective, not cosmetic. - const skillPerm = createMemo<Record<string, Action>>(() => { - const perm = sync.data.config.permission - if (!perm || typeof perm === "string") return {} - const skill = (perm as Record<string, unknown>).skill - if (!skill || typeof skill === "string") return {} - return skill as Record<string, Action> - }) - const enabled = (name: string) => skillPerm()[name] !== "deny" + const enabled = (name: string) => skillAction(sync.data.config.permission, name) !== "deny" - async function toggle(name: string, next: boolean) { - const map: Record<string, Action> = { ...skillPerm(), [name]: next ? "allow" : "deny" } - const perm = sync.data.config.permission - const base = perm && typeof perm === "object" ? perm : {} - sync.set("config", "permission", { ...base, skill: map }) - try { - await sync.updateConfig({ permission: { skill: map } } as Config) - } catch (err) { - showToast({ variant: "error", title: "Failed to update skill", description: message(err) }) + function markPermissionPending(name: string, delta: number) { + setPermissionPending((current) => { + const next = { ...current } + const count = (next[name] ?? 0) + delta + if (count > 0) next[name] = count + else delete next[name] + return next + }) + } + + function toggle(name: string, next: boolean) { + const before = sync.data.config.permission + const change = skillPermissionChange(before, name, next) + const version = (permissionVersions.get(name) ?? 0) + 1 + permissionVersions.set(name, version) + + // A click updates this switch in the same frame. Disk writes stay ordered, + // but a slow write never disables every other skill in the catalog. + sync.set("config", "permission", change.optimistic as Config["permission"]) + markPermissionPending(name, 1) + + const persist = async () => { + try { + const latest = skillPermissionChange(sync.data.config.permission, name, next) + await sync.updateConfig({ permission: latest.patch } as Config) + } catch (error) { + if (permissionVersions.get(name) === version) { + const restored = restoreExactSkillPermission(sync.data.config.permission, before, name) + sync.set("config", "permission", restored as Config["permission"]) + } + showToast({ variant: "error", title: "Failed to update skill", description: message(error) }) + } finally { + markPermissionPending(name, -1) + } } + permissionWrites = permissionWrites.then(persist, persist) } - const all = () => skills() ?? [] + const all = () => skills() ?? initialSkills const enabledCount = createMemo(() => all().filter((s) => enabled(s.name)).length) const categories = createMemo(() => { @@ -114,10 +239,10 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { counts.set(cat, (counts.get(cat) ?? 0) + 1) } return [ - { id: "all", label: "All", count: all().length }, + { id: "all", label: "All categories", count: all().length }, ...[...counts.entries()] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([id, count]) => ({ id, label: id, count })), + .map(([id, count]) => ({ id, label: displayLabel(id), count })), ] }) @@ -155,34 +280,116 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { return [...by.entries()].sort((a, b) => a[0].localeCompare(b[0])) }) + // The complete catalog can contain hundreds of skills. Keep first paint and + // hidden-panel cost bounded, then reveal more as the user approaches the end. + const visibleShelves = createMemo(() => { + let remaining = visibleRows() + const result: Array<[string, Skill[]]> = [] + for (const [name, items] of shelves()) { + if (remaining <= 0) break + const visible = items.slice(0, remaining) + if (visible.length) result.push([name, visible]) + remaining -= visible.length + } + return result + }) + const hasMoreRows = createMemo(() => visibleRows() < filtered().length) + + const visibleSummary = createMemo(() => { + if (filtered().length === all().length) return `${all().length} available` + return `${filtered().length} of ${all().length} shown` + }) + const filtersActive = createMemo(() => !!search().trim() || category() !== "all" || source() !== "all") + + function clearFilters() { + setSearch("") + setCategory("all") + setSource("all") + setVisibleRows(INITIAL_SKILL_ROWS) + } + + createEffect(() => { + all().length + setVisibleRows(INITIAL_SKILL_ROWS) + }) + + onMount(() => { + const panel = workspaceElement?.closest<HTMLElement>("[data-settings-panel]") + if (!panel) return + const observer = new MutationObserver(() => { + if (panel.hidden) setVisibleRows(INITIAL_SKILL_ROWS) + }) + observer.observe(panel, { attributes: true, attributeFilter: ["hidden"] }) + onCleanup(() => observer.disconnect()) + }) + + function loadMoreRows() { + setVisibleRows((current) => Math.min(filtered().length, current + SKILL_ROW_BATCH)) + } + + function handleCatalogScroll(event: Event) { + if (!hasMoreRows()) return + const target = event.currentTarget as HTMLElement + if (target.scrollHeight - target.scrollTop - target.clientHeight < 480) loadMoreRows() + } + return ( - <div class="skills-workspace" data-layout={props.embedded ? "settings" : "workspace"}> + <div ref={workspaceElement} class="skills-workspace" data-layout={props.embedded ? "settings" : "workspace"}> <div class="skills-workspace__header"> - <Show when={!props.embedded}> - <div class="skills-workspace__heading"> - <div> - <h1>Skills</h1> - <p>Playbooks available to this workspace and its research agents.</p> - </div> - <div class="skills-workspace__summary"> - <span>{enabledCount()} enabled</span> - <span>{all().length} total</span> - </div> + <div class="skills-workspace__heading"> + <div class="skills-workspace__heading-copy"> + <Show + when={!props.embedded} + fallback={ + <> + <h2>Available skills</h2> + <p>Choose the playbooks OpenScience can use for research work.</p> + </> + } + > + <> + <h1>Skills</h1> + <p>Playbooks available to this workspace and its research agents.</p> + </> + </Show> </div> - </Show> - <Show when={props.embedded}> - <div class="skills-workspace__summary"> + <div class="skills-workspace__summary" aria-live="polite"> + <span>{visibleSummary()}</span> + <span aria-hidden="true">·</span> <span>{enabledCount()} enabled</span> - <span>{all().length} total</span> </div> - </Show> + </div> <Show when={view() === "list"}> <div class="skills-workspace__toolbar"> - <Toolbar> - <FilterMenu options={sources()} value={source()} onSelect={(value) => setSource(value as SourceView)} /> - <FilterMenu options={categories()} value={category()} onSelect={setCategory} /> - <SearchInput value={search()} onInput={setSearch} placeholder="Search skills" /> + <div class="settings-toolbar skills-workspace__toolbar-controls"> + <SearchInput + value={search()} + onInput={(value) => { + setSearch(value) + setVisibleRows(INITIAL_SKILL_ROWS) + }} + placeholder="Search skills" + ariaLabel="Search skills" + /> + <FilterMenu + options={sources()} + value={source()} + onSelect={(value) => { + setSource(value as SourceView) + setVisibleRows(INITIAL_SKILL_ROWS) + }} + ariaLabel="Filter skills by source" + /> + <FilterMenu + options={categories()} + value={category()} + onSelect={(value) => { + setCategory(value) + setVisibleRows(INITIAL_SKILL_ROWS) + }} + ariaLabel="Filter skills by category" + /> <AddMenu label="Add skill" items={[ @@ -206,7 +413,7 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { }, ]} /> - </Toolbar> + </div> </div> </Show> </div> @@ -223,7 +430,7 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { }} /> - <div class="atlas-scroll skills-workspace__body"> + <div class="atlas-scroll skills-workspace__body" onScroll={handleCatalogScroll}> <div class="skills-workspace__content"> <Show when={view() === "scratch"}> <ScratchForm @@ -273,44 +480,75 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { </Show> <Show when={view() === "list"}> - <Show when={!skills.loading} fallback={<div style={loadingStyle()}>Loading skills…</div>}> + <Show + when={!skills.loading || all().length > 0} + fallback={<CatalogState icon="refresh" title="Loading skills" hint="Fetching the latest catalog…" />} + > <Show - when={filtered().length > 0} + when={!skills.error || all().length > 0} fallback={ - <div style={{ "padding-top": "36px" }}> - <EmptyState - icon="brain" - title={ - search() || category() !== "all" || source() !== "all" ? "No matching skills" : "No skills yet" - } - hint="Write one from scratch, upload a SKILL.md, or import from a public GitHub repo." - /> - </div> + <CatalogState + icon="alert-circle" + title="Skills could not be loaded" + hint={message(skills.error)} + action="Try again" + onAction={() => void skillsCtl.refetch()} + /> } > - <div class="skills-workspace__list"> - <For each={shelves()}> - {([cat, items]) => ( - <section class="skills-workspace__group"> - <div class="skills-workspace__group-heading"> - <span class="atlas-section-label">{cat}</span> - <span>{items.length}</span> - </div> - <div class="skills-workspace__rows"> - <For each={items}> - {(skill) => ( - <SkillRow - skill={skill} - on={enabled(skill.name)} - onToggle={(v) => void toggle(skill.name, v)} - /> - )} - </For> - </div> - </section> - )} - </For> - </div> + <Show + when={filtered().length > 0} + fallback={ + <Show + when={filtersActive()} + fallback={ + <EmptyState + icon="brain" + title="No skills yet" + hint="Write one from scratch, upload a SKILL.md, or import from a public GitHub repository." + /> + } + > + <CatalogState + icon="magnifying-glass" + title="No matching skills" + hint="Try a different search, source, or category." + action="Clear filters" + onAction={clearFilters} + /> + </Show> + } + > + <div class="skills-workspace__list"> + <For each={visibleShelves()}> + {([cat, items], index) => ( + <section class="skills-workspace__group" aria-labelledby={`skills-group-${index()}`}> + <div class="skills-workspace__group-heading"> + <h3 id={`skills-group-${index()}`}>{displayLabel(cat)}</h3> + <span>{items.length}</span> + </div> + <ul class="skills-workspace__rows"> + <For each={items}> + {(skill) => ( + <SkillRow + skill={skill} + on={enabled(skill.name)} + saving={Boolean(permissionPending()[skill.name])} + onToggle={(v) => toggle(skill.name, v)} + /> + )} + </For> + </ul> + </section> + )} + </For> + <Show when={hasMoreRows()}> + <button type="button" class="skills-workspace__more" onClick={loadMoreRows}> + Show more skills + </button> + </Show> + </div> + </Show> </Show> </Show> </Show> @@ -338,29 +576,43 @@ export default function SkillsPage(props: { embedded?: boolean }): JSX.Element { } } -function SkillRow(props: { skill: Skill; on: boolean; onToggle: (v: boolean) => void }): JSX.Element { +function SkillRow(props: { skill: Skill; on: boolean; saving: boolean; onToggle: (v: boolean) => void }): JSX.Element { const source = () => sourceOf(props.skill) return ( - <div class="skills-workspace__row" data-enabled={props.on ? "true" : "false"}> + <li + class="skills-workspace__row" + data-enabled={props.on ? "true" : "false"} + data-source={source()} + data-saving={props.saving ? "true" : undefined} + aria-busy={props.saving ? "true" : undefined} + > <div class="skills-workspace__identity"> - <strong title={props.skill.name}>{props.skill.name}</strong> - <span> - <i style={{ background: SOURCE_DOT[source()] }} /> - {SOURCE_LABEL[source()]} - </span> + <div class="skills-workspace__identity-copy"> + <strong title={props.skill.name}>{displayLabel(props.skill.name)}</strong> + <span> + <code>/{props.skill.name}</code> + <span aria-hidden="true">·</span> + {SOURCE_LABEL[source()]} + </span> + </div> </div> - <Show when={props.skill.description}> - <p>{props.skill.description}</p> - </Show> - - <div class="skills-workspace__tags"> - <For each={(props.skill.tags ?? []).slice(0, 3)}>{(tag) => <span>{tag}</span>}</For> + <div class="skills-workspace__details"> + <p data-empty={!props.skill.description}>{props.skill.description || "No description provided."}</p> + <Show when={(props.skill.tags ?? []).length > 0}> + <div class="skills-workspace__tags" aria-label="Skill tags"> + <For each={(props.skill.tags ?? []).slice(0, 2)}> + {(tag) => <span class="settings-chip">{displayLabel(tag)}</span>} + </For> + </div> + </Show> </div> - <Switch data-action="skill-toggle" checked={props.on} onChange={props.onToggle} hideLabel> - {props.skill.name} - </Switch> - </div> + <div class="skills-workspace__toggle"> + <Switch data-action="skill-toggle" checked={props.on} onChange={props.onToggle} hideLabel> + {props.on ? `Disable ${props.skill.name}` : `Enable ${props.skill.name}`} + </Switch> + </div> + </li> ) } @@ -374,9 +626,17 @@ function ScratchForm(props: { const [body, setBody] = createSignal("") const valid = () => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name().trim()) && description().trim().length > 0 return ( - <div class="flex flex-col gap-4 max-w-[680px]"> - <span class="atlas-section-label">Write a new skill</span> - <div class="flex flex-col gap-4 p-5 border border-border-weak-base rounded-[8px] bg-surface-base/40"> + <div class="skills-workspace__form"> + <div class="skills-workspace__form-heading"> + <div class="skills-workspace__form-icon" aria-hidden="true"> + <Icon name="pencil-line" size="small" /> + </div> + <div> + <h3>Write a new skill</h3> + <p>Create a focused playbook that agents can load when it is relevant.</p> + </div> + </div> + <div class="skills-workspace__form-fields"> <FormField label="Name" value={name()} onInput={setName} placeholder="my-skill (letters, digits, - and _)" /> <FormField label="Description" @@ -392,7 +652,7 @@ function ScratchForm(props: { mono placeholder="Step-by-step guidance, code examples, pitfalls…" /> - <div class="flex items-center gap-2"> + <div class="skills-workspace__form-actions"> <FormButton label={props.busy ? "Creating…" : "Create skill"} disabled={props.busy || !valid()} @@ -408,15 +668,23 @@ function ScratchForm(props: { function GithubForm(props: { busy: boolean; onCancel: () => void; onInstall: (url: string) => void }): JSX.Element { const [url, setUrl] = createSignal("") return ( - <div class="flex flex-col gap-4 max-w-[680px]"> - <span class="atlas-section-label">Import from GitHub</span> - <div class="flex flex-col gap-4 p-5 border border-border-weak-base rounded-[8px] bg-surface-base/40"> + <div class="skills-workspace__form"> + <div class="skills-workspace__form-heading"> + <div class="skills-workspace__form-icon" aria-hidden="true"> + <Icon name="github" size="small" /> + </div> + <div> + <h3>Import from GitHub</h3> + <p>Install one or more skills from a public repository.</p> + </div> + </div> + <div class="skills-workspace__form-fields"> <FormField label="Repository URL" value={url()} onInput={setUrl} placeholder="https://github.com/owner/repo" /> - <p class="text-12-regular text-text-weak flex items-start gap-1.5"> - <Icon name="check-small" size="small" class="text-icon-weak-base mt-0.5" /> + <p class="skills-workspace__security-note"> + <Icon name="shield" size="small" /> Skills are fetched, screened by a multi-layer security review, and only installed if they pass. </p> - <div class="flex items-center gap-2"> + <div class="skills-workspace__form-actions"> <FormButton label={props.busy ? "Installing…" : "Install"} disabled={props.busy || !url().trim()} @@ -429,14 +697,27 @@ function GithubForm(props: { busy: boolean; onCancel: () => void; onInstall: (ur ) } -function loadingStyle(): JSX.CSSProperties { - return { - padding: "48px 0", - "text-align": "center", - "font-family": FONT_SANS, - "font-size": "13px", - color: "var(--color-text-muted)", - } +function CatalogState(props: { + icon: "refresh" | "alert-circle" | "magnifying-glass" + title: string + hint: string + action?: string + onAction?: () => void +}): JSX.Element { + return ( + <div class="skills-workspace__state" role={props.icon === "alert-circle" ? "alert" : "status"}> + <div class="settings-empty-state__icon skills-workspace__state-icon" aria-hidden="true"> + <Icon name={props.icon} size="normal" /> + </div> + <strong>{props.title}</strong> + <p>{props.hint}</p> + <Show when={props.action && props.onAction}> + <button type="button" class="settings-button" data-variant="ghost" onClick={props.onAction}> + {props.action} + </button> + </Show> + </div> + ) } function frontmatterName(content: string): string | undefined { diff --git a/frontend/workspace/src/atlas/TerminalSurface.css b/frontend/workspace/src/atlas/TerminalSurface.css new file mode 100644 index 00000000..b2ebb180 --- /dev/null +++ b/frontend/workspace/src/atlas/TerminalSurface.css @@ -0,0 +1,426 @@ +/* Terminal surface structure and visual treatment stay in one component-owned + * stylesheet. Keeping these selectors out of atlas.css prevents async chunk + * order from changing a mounted terminal during session navigation. */ +/* Contextual project terminal */ + +.terminal-surface { + min-width: 0; + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; + background: var(--color-bg); + color: var(--color-text); + font-family: inherit; + font-size: 12px; + line-height: 1.4; +} + +.terminal-surface__search button { + appearance: none; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0; + border: 0; + border-radius: var(--atlas-radius-xs); + font-family: var(--font-family-sans); + font-size: 12px; + color: var(--color-text-muted); + background: transparent; + cursor: pointer; + transition: + color 120ms ease, + background 120ms ease; +} + +.terminal-surface__search button:hover:not(:disabled) { + color: var(--color-text); + background: var(--color-bg-subtle); +} + +.terminal-surface__search button:disabled { + opacity: 0.32; + cursor: default; +} + +.terminal-surface__search { + min-height: 44px; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 5px; + padding: 4px 6px 4px 10px; + color: var(--color-text-muted); + background: var(--color-bg); + border-bottom: 1px solid var(--color-border); + animation: atlas-terminal-search-in 120ms ease-out; +} + +.terminal-surface__search input { + min-width: 40px; + flex: 1; + height: 32px; + padding: 0 7px; + border: 1px solid var(--color-border); + border-radius: var(--atlas-radius-xs); + outline: 0; + font-family: var(--font-family-mono); + font-size: 12px; + color: var(--color-text); + background: var(--color-bg-elevated); +} + +.terminal-surface__search input:focus { + border-color: color-mix(in srgb, var(--color-accent) 48%, var(--color-border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 9%, transparent); +} + +.terminal-surface__search input::-webkit-search-cancel-button { + display: none; +} + +.terminal-surface__search-count { + min-width: 34px; + font-family: var(--font-family-mono); + font-size: 12px; + color: var(--color-text-muted); + text-align: right; +} + +@keyframes atlas-terminal-search-in { + from { + opacity: 0; + transform: translateY(-2px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.terminal-surface__new, +.terminal-surface__empty button { + appearance: none; + border: 0; + font-family: var(--font-family-sans); + color: var(--color-text-muted); + background: transparent; + cursor: pointer; + transition: + color 120ms ease, + background 120ms ease, + transform 100ms ease; +} + +.terminal-surface__new { + height: 32px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 7px; + border-radius: var(--atlas-radius-xs); + font-size: 12px; +} + +.terminal-surface__new:hover:not(:disabled), +.terminal-surface__empty button:hover:not(:disabled) { + color: var(--color-text); + background: var(--color-bg-subtle); +} + +.terminal-surface__new:active:not(:disabled), +.terminal-surface__empty button:active:not(:disabled) { + transform: translateY(1px); +} + +.terminal-surface__new:disabled, +.terminal-surface__empty button:disabled { + color: var(--color-text-faint); + cursor: wait; +} + +.terminal-surface__error, +.terminal-surface__authority { + flex: 0 0 auto; + display: flex; + min-height: 38px; + box-sizing: border-box; + align-items: center; + gap: 8px; + margin: 10px 12px 0; + padding: 7px 9px; + border: 1px solid var(--color-border); + border-radius: var(--atlas-radius-xs); + background: var(--color-bg-elevated); + color: var(--color-text-muted); + font-family: inherit; + font-size: 12px; + line-height: 1.4; +} + +.terminal-surface__error { + border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border)); + color: var(--color-error); +} + +.terminal-surface__error > span { + min-width: 0; + flex: 1; +} + +.terminal-surface__error button { + display: inline-flex; + min-height: 30px; + flex: none; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--color-error) 26%, var(--color-border)); + border-radius: var(--atlas-radius-xs); + background: transparent; + color: currentColor; + font: inherit; + cursor: pointer; +} + +.terminal-surface__error button:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-error) 7%, transparent); +} + +.terminal-surface__error button:disabled { + opacity: 0.5; + cursor: wait; +} + +.terminal-surface__empty { + min-height: 0; + flex: 1; + display: grid; + place-content: center; + justify-items: center; + gap: 8px; + padding: 32px 24px; + font-family: var(--font-family-sans); + color: var(--color-text-muted); + text-align: center; +} + +.terminal-surface__empty-mark { + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 5px; + font-family: var(--font-family-mono); + font-size: 18px; + font-weight: var(--font-weight-medium); + letter-spacing: 0; + color: var(--color-text-muted); +} + +.terminal-surface__empty strong { + color: var(--color-text); + font-size: 13px; + font-weight: var(--font-weight-medium); +} + +.terminal-surface__empty p { + max-width: 34ch; + margin: 0; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; + text-wrap: pretty; +} + +.terminal-surface__eyebrow { + color: var(--color-text-muted); + font-family: inherit; + font-size: 12px; +} + +.terminal-surface__empty button { + min-height: 34px; + margin-top: 4px; + padding: 0 12px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: var(--color-bg-subtle); + font-size: 12px; +} + +.terminal-surface__tabs-row { + min-height: 44px; + flex: 0 0 auto; + display: flex; + align-items: center; + padding: 0 8px; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.terminal-surface__tabs { + min-width: 0; + min-height: 44px; + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 4px; + padding: 5px 0; + overflow-x: auto; + scrollbar-width: none; +} + +.terminal-surface__tabs-row > .terminal-surface__new { + height: 32px; + flex: 0 0 auto; + margin: 0 0 0 6px; +} + +.terminal-surface__tabs-row > .terminal-surface__new:hover:not(:disabled) { + background: var(--color-bg-subtle); +} + +.terminal-surface__tabs::-webkit-scrollbar { + display: none; +} + +.terminal-surface__tab-shell { + flex: 0 0 auto; + display: flex; + align-items: center; + min-width: 0; + height: 32px; + color: var(--color-text-muted); + background: transparent; + border-radius: var(--atlas-radius-xs); +} + +.terminal-surface__tab-shell:hover { + color: var(--color-text-muted); + background: var(--color-bg-subtle); +} + +.terminal-surface__tab-shell[data-active="true"] { + color: var(--color-text); + background: var(--color-bg-subtle); +} + +.terminal-surface__tab, +.terminal-surface__close { + appearance: none; + height: 32px; + color: currentColor; + background: transparent; + border: 0; + cursor: pointer; +} + +.terminal-surface__tab { + max-width: 132px; + overflow: hidden; + padding: 0 5px 0 8px; + font-family: inherit; + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 32px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-surface__close { + width: 32px; + display: grid; + place-items: center; + padding: 0; + border-radius: var(--atlas-radius-xs); + opacity: 0.58; +} + +.terminal-surface__close:hover { + color: var(--color-text); + opacity: 1; +} + +.terminal-surface__viewport { + min-width: 0; + min-height: 0; + flex: 1; + position: relative; + overflow: hidden; +} + +.terminal-surface__connecting { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--color-bg); + color: var(--color-text-muted); + font-family: inherit; + font-size: 12px; +} + +.terminal-surface__connecting-mark { + display: inline-flex; + color: var(--color-text-muted); + animation: atlas-pulse 1.2s ease-in-out infinite; +} + +.terminal-surface__terminal { + position: absolute; + inset: 0; + display: none; +} + +.terminal-surface__terminal[data-active="true"] { + display: block; +} + +.terminal-surface__terminal [data-component="terminal"] { + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 12px 14px !important; +} + +.terminal-surface button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +@media (pointer: coarse) { + .terminal-surface__search button, + .terminal-surface__new, + .terminal-surface__empty button, + .terminal-surface__tab, + .terminal-surface__close { + min-width: 44px; + min-height: 44px; + } + + .terminal-surface__search input, + .terminal-surface__tab-shell { + min-height: 44px; + } + .terminal-surface__error button { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .terminal-surface__search { + animation: none; + } + + .terminal-surface__connecting-mark { + animation: none; + } +} diff --git a/frontend/workspace/src/atlas/TerminalSurface.tsx b/frontend/workspace/src/atlas/TerminalSurface.tsx index e494d2fa..30597024 100644 --- a/frontend/workspace/src/atlas/TerminalSurface.tsx +++ b/frontend/workspace/src/atlas/TerminalSurface.tsx @@ -3,9 +3,19 @@ import { createStore } from "solid-js/store" import { preloadTerminal, Terminal, type TerminalController, type TerminalSearchResult } from "@/components/terminal" import { useSDK } from "@/context/sdk" import { useTerminal } from "@/context/terminal" -import { IconChevronLeft, IconChevronRight, IconPlus, IconSearch, IconX } from "@/atlas/shared/Icon" +import { + IconAlertCircle, + IconChevronLeft, + IconChevronRight, + IconPlus, + IconRefresh, + IconSearch, + IconTerminal, + IconX, +} from "@/atlas/shared/Icon" import { terminalEndpointAvailable } from "@/atlas/terminal-endpoint" import { useExecutionAuthority } from "@/atlas/use-execution-authority" +import "@/atlas/TerminalSurface.css" const EMPTY_RESULT: TerminalSearchResult = { current: 0, total: 0 } @@ -81,6 +91,28 @@ export function TerminalSurface(): JSX.Element { .finally(() => setState("starting", false)) } + const recover = () => { + const id = active()?.id + if (!id) { + launch() + return + } + if (!available() || state.starting || !authority.allowed()) return + setState({ starting: true, connecting: true, error: "" }) + void terminal + .clone(id) + .then((replacement) => { + if (!replacement) throw new Error("The terminal tab is no longer available.") + }) + .catch((cause: unknown) => { + setState({ + connecting: false, + error: cause instanceof Error ? cause.message : "OpenScience could not reconnect the terminal.", + }) + }) + .finally(() => setState("starting", false)) + } + const autostart = { requested: false } createEffect(() => { if (autostart.requested || !available() || !terminal.ready() || terminal.all().length || state.starting) return @@ -94,7 +126,12 @@ export function TerminalSurface(): JSX.Element { <Show when={state.error}> {(message) => ( <div class="terminal-surface__error" role="alert"> - {message()} + <IconAlertCircle size={14} strokeWidth={1.5} /> + <span>{message()}</span> + <button type="button" onClick={recover} disabled={state.starting || !authority.allowed()}> + <IconRefresh size={12} strokeWidth={1.5} /> + {state.starting ? "Retrying…" : "Try again"} + </button> </div> )} </Show> @@ -112,10 +149,10 @@ export function TerminalSurface(): JSX.Element { fallback={ <div class="terminal-surface__empty"> <span class="terminal-surface__empty-mark" aria-hidden="true"> - >_ + <IconTerminal size={17} strokeWidth={1.35} /> </span> <strong>Local terminal unavailable</strong> - <p>Connect OpenScience to a local server to run a shell inside this project.</p> + <p>Connect to the local OpenScience server to run commands inside this project.</p> </div> } > @@ -123,7 +160,7 @@ export function TerminalSurface(): JSX.Element { when={terminal.ready()} fallback={ <div class="terminal-surface__empty" aria-live="polite"> - <span class="terminal-surface__eyebrow">Preparing session shell…</span> + <span class="terminal-surface__eyebrow">Preparing terminal…</span> </div> } > @@ -132,10 +169,10 @@ export function TerminalSurface(): JSX.Element { fallback={ <div class="terminal-surface__empty"> <span class="terminal-surface__empty-mark" aria-hidden="true"> - >_ + <IconTerminal size={17} strokeWidth={1.35} /> </span> - <strong>Run commands in this session</strong> - <p>Start a persistent shell, then open more tabs whenever you need parallel work.</p> + <strong>Project terminal</strong> + <p>Start a clean shell in this session. Open another tab only when you need parallel work.</p> <button type="button" onClick={launch} @@ -239,9 +276,9 @@ export function TerminalSurface(): JSX.Element { <Show when={state.connecting}> <div class="terminal-surface__connecting" role="status" aria-live="polite"> <span class="terminal-surface__connecting-mark" aria-hidden="true"> - >_ + <IconTerminal size={14} strokeWidth={1.35} /> </span> - <span>Starting session shell…</span> + <span>Starting terminal…</span> </div> </Show> <For each={terminal.all()}> diff --git a/frontend/workspace/src/atlas/Toast.test.ts b/frontend/workspace/src/atlas/Toast.test.ts new file mode 100644 index 00000000..99fd7589 --- /dev/null +++ b/frontend/workspace/src/atlas/Toast.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" + +const source = await Bun.file(new URL("./Toast.tsx", import.meta.url)).text() + +describe("workspace toast contract", () => { + test("uses the shared accessible toast region for every notification path", () => { + expect(source).toContain("showToast({") + expect(source).toContain('<Toast.Region aria-label="Notifications"') + expect(source).toContain("toaster.dismiss(id)") + expect(source).not.toContain("setTimeout(") + expect(source).not.toContain("FONT_MONO") + }) + + test("normalizes legacy lowercase titles without transforming command or path descriptions", () => { + expect(source).toContain("title: sentenceCase(input.title)") + expect(source).toContain("description: input.description") + }) +}) diff --git a/frontend/workspace/src/atlas/Toast.tsx b/frontend/workspace/src/atlas/Toast.tsx index 964969c2..6b9ee174 100644 --- a/frontend/workspace/src/atlas/Toast.tsx +++ b/frontend/workspace/src/atlas/Toast.tsx @@ -1,36 +1,44 @@ -import { createSignal, type JSX, For, Show } from "solid-js" -import { Portal } from "solid-js/web" -import { FONT_MONO, FONT_SANS } from "@/styles/tokens" -import { StatusDot, type StatusKind } from "@/atlas/shared/StatusDot" -import { IconX } from "@/atlas/shared/Icon" +import { Toast, showToast, toaster, type ToastVariant } from "@synsci/ui/toast" export type ToastKind = "info" | "success" | "warning" | "error" -interface Toast { - id: string +interface ToastInput { title: string description?: string kind: ToastKind ttl_ms?: number } -const [toasts, setToasts] = createSignal<Toast[]>([]) +const variantFor: Record<ToastKind, ToastVariant> = { + info: "default", + success: "success", + warning: "default", + error: "error", +} -let nextId = 1 +const iconFor = { + success: "circle-check", + error: "circle-x", +} as const + +function sentenceCase(value: string) { + return value.replace(/^\p{Ll}/u, (letter) => letter.toLocaleUpperCase()) +} export const toast = { - push(t: Omit<Toast, "id">) { - const id = `toast-${nextId++}` - const full: Toast = { ...t, id } - setToasts((prev) => [...prev, full]) - const ttl = t.ttl_ms ?? 4500 - if (ttl > 0) { - setTimeout(() => toast.dismiss(id), ttl) - } - return id + push(input: ToastInput) { + const persistent = input.ttl_ms === 0 + return showToast({ + variant: variantFor[input.kind], + icon: input.kind === "success" ? iconFor.success : input.kind === "error" ? iconFor.error : undefined, + title: sentenceCase(input.title), + description: input.description, + duration: persistent ? undefined : (input.ttl_ms ?? 4500), + persistent, + }) }, - dismiss(id: string) { - setToasts((prev) => prev.filter((t) => t.id !== id)) + dismiss(id: number) { + toaster.dismiss(id) }, info(title: string, description?: string) { return toast.push({ kind: "info", title, description }) @@ -46,96 +54,11 @@ export const toast = { }, } -const statusFor: Record<ToastKind, StatusKind> = { - info: "muted", - success: "active", - warning: "pending", - error: "error", -} - -export function ToastContainer(): JSX.Element { - return ( - <Portal> - <div - style={{ - position: "fixed", - bottom: "16px", - right: "16px", - display: "flex", - "flex-direction": "column", - gap: "8px", - "z-index": 1000, - "max-width": "380px", - "pointer-events": "none", - }} - > - <For each={toasts()}> - {(t) => ( - <div - class="atlas-slide-up" - style={{ - background: "var(--color-surface-solid)", - border: "1px solid var(--color-border)", - "border-left": - t.kind === "error" - ? "3px solid var(--color-error)" - : t.kind === "warning" - ? "3px solid var(--color-warning)" - : t.kind === "success" - ? "3px solid var(--color-success)" - : "3px solid var(--color-text-faint)", - "border-radius": "4px", - "box-shadow": "var(--shadow-md)", - padding: "10px 14px", - display: "flex", - gap: "10px", - "align-items": "flex-start", - "pointer-events": "auto", - "min-width": "260px", - }} - > - <StatusDot status={statusFor[t.kind]} pulse={t.kind === "warning"} /> - <div style={{ flex: 1 }}> - <div - style={{ - "font-family": FONT_MONO, - "font-size": "11.5px", - "font-weight": 500, - color: "var(--color-text)", - }} - > - {t.title} - </div> - <Show when={t.description}> - <div - style={{ - "font-family": FONT_SANS, - "font-size": "11.5px", - color: "var(--color-text-muted)", - "line-height": 1.5, - "margin-top": "2px", - }} - > - {t.description} - </div> - </Show> - </div> - <button - onClick={() => toast.dismiss(t.id)} - style={{ - all: "unset", - cursor: "pointer", - color: "var(--color-text-faint)", - display: "inline-flex", - padding: "2px", - }} - > - <IconX size={11} strokeWidth={1.5} /> - </button> - </div> - )} - </For> - </div> - </Portal> - ) +/** + * One region serves both the legacy `toast.*` facade and direct `showToast` + * calls. Kobalte owns live-region semantics, pause-on-hover/focus, dismissal, + * swipe handling, and the labelled 32px close control. + */ +export function ToastContainer() { + return <Toast.Region aria-label="Notifications" /> } diff --git a/frontend/workspace/src/atlas/Wordmark.tsx b/frontend/workspace/src/atlas/Wordmark.tsx index 52ef8b1a..cafaf4f9 100644 --- a/frontend/workspace/src/atlas/Wordmark.tsx +++ b/frontend/workspace/src/atlas/Wordmark.tsx @@ -11,23 +11,22 @@ interface WordmarkProps { export function Wordmark(props: WordmarkProps): JSX.Element { const size = () => props.size ?? "md" const px = () => - size() === "lg" ? { logo: 30, text: 28 } : size() === "sm" ? { logo: 22, text: 18 } : { logo: 26, text: 22 } - return ( - <button - onClick={props.onClick} - class="atlas-wordmark" - style={{ - all: "unset", - cursor: props.onClick ? "pointer" : "default", - display: "inline-flex", - "align-items": "center", - gap: size() === "sm" ? "8px" : "10px", - }} - > + size() === "lg" ? { logo: 30, text: 28 } : size() === "sm" ? { logo: 20, text: 14.5 } : { logo: 26, text: 22 } + const weight = () => (size() === "sm" ? "var(--font-weight-emphasis)" : "var(--font-weight-regular)") + const rootStyle = (): JSX.CSSProperties => ({ + all: "unset", + cursor: props.onClick ? "pointer" : "default", + display: "inline-flex", + "align-items": "center", + gap: size() === "sm" ? "7px" : "10px", + }) + const content = () => ( + <> <Show when={!props.textOnly}> <img src="/openscience-logo.png" alt="" + aria-hidden="true" style={{ width: `${px().logo}px`, height: `${px().logo}px`, @@ -37,10 +36,11 @@ export function Wordmark(props: WordmarkProps): JSX.Element { /> </Show> <span + aria-hidden="true" style={{ "font-family": FONT_SANS, "font-size": `${px().text}px`, - "font-weight": 400, + "font-weight": weight(), "letter-spacing": "-0.02em", color: "var(--color-text)", "white-space": "nowrap", @@ -48,6 +48,20 @@ export function Wordmark(props: WordmarkProps): JSX.Element { > OpenScience </span> - </button> + </> + ) + return ( + <Show + when={props.onClick} + fallback={ + <span class="atlas-wordmark" role="img" aria-label="OpenScience" style={rootStyle()}> + {content()} + </span> + } + > + <button type="button" class="atlas-wordmark" aria-label="OpenScience" onClick={props.onClick} style={rootStyle()}> + {content()} + </button> + </Show> ) } diff --git a/frontend/workspace/src/atlas/compute-surface-style.test.ts b/frontend/workspace/src/atlas/compute-surface-style.test.ts index 15433f42..7d85710a 100644 --- a/frontend/workspace/src/atlas/compute-surface-style.test.ts +++ b/frontend/workspace/src/atlas/compute-surface-style.test.ts @@ -1,19 +1,34 @@ import { describe, expect, test } from "bun:test" -const css = await Bun.file(new URL("../styles/atlas.css", import.meta.url)).text() +const [css, strip, shell] = await Promise.all([ + Bun.file(new URL("./ComputeSurface.css", import.meta.url)).text(), + Bun.file(new URL("./HostStrip.css", import.meta.url)).text(), + Bun.file(new URL("../styles/atlas.css", import.meta.url)).text(), +]) describe("compute surface styling", () => { - test("uses a soft, readable segmented control for compute views", () => { - expect(css).toContain(".compute-surface__tabs") - expect(css).toContain("border-radius: 16px") - expect(css).toContain("min-height: 44px") - expect(css).toContain("font-size: 14px") - expect(css).toContain('.compute-surface__tab[data-active="true"]') + test("uses a quiet operational ledger instead of a card dashboard", () => { + expect(css).toContain(".compute-surface .kernel-card") + expect(css).toContain("border-radius: var(--compute-radius-card)") + expect(css).toContain("box-shadow: none") + expect(css).not.toContain(".compute-surface__atlas") + expect(css).toContain("gap: 4px") + expect(css).toContain("transition: background-color 140ms ease") + expect(css).toContain(".remote-job-card__actions button:focus-visible") + expect(css).toContain("font-weight: var(--font-weight-medium)") + expect(strip).toContain("height: 2px") + expect(css).not.toContain(".compute-surface__tabs") }) - test("keeps the selected compute view as the only scrolling content area", () => { + test("keeps the live inventory as the only scrolling content area", () => { expect(css).toContain(".compute-surface__panel") expect(css).toContain("min-height: 0") - expect(css).toContain("overflow: hidden") + expect(css).toContain("overflow-y: auto") + expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}/) + expect(strip).not.toMatch(/#[0-9a-fA-F]{3,8}/) + }) + + test("keeps compute styling component-owned instead of layering legacy shell overrides", () => { + expect(shell).not.toMatch(/\.(?:compute-surface|kernel-panel|kernel-card)(?:[\s_:{.[#]|$)/) }) }) diff --git a/frontend/workspace/src/atlas/dialogs.tsx b/frontend/workspace/src/atlas/dialogs.tsx index d46cc1fd..c6a3b4f4 100644 --- a/frontend/workspace/src/atlas/dialogs.tsx +++ b/frontend/workspace/src/atlas/dialogs.tsx @@ -1,30 +1,21 @@ -import { createSignal, Show, type JSX } from "solid-js" +import { createSignal, type JSX } from "solid-js" import { Button } from "@synsci/ui/button" +import { Dialog as ModalDialog } from "@synsci/ui/dialog" +import { TextField } from "@synsci/ui/text-field" import { useDialog } from "@synsci/ui/context/dialog" -import { FONT_CODE } from "@/styles/tokens" -type Dialog = ReturnType<typeof useDialog> +type DialogController = ReturnType<typeof useDialog> -/** - * Promise-based replacements for window.confirm / window.prompt / window.alert - * that render inside the app's dialog portal so they match the atlas UI and - * don't reflow or steal focus the way native dialogs do. - */ - -function card(): JSX.CSSProperties { - return { - width: "420px", - "max-width": "92vw", - background: "var(--color-surface-solid)", - border: "1px solid var(--color-border-strong)", - "border-radius": "var(--radius-xl)", - "box-shadow": "var(--shadow-md)", - overflow: "hidden", - } +const actions: JSX.CSSProperties = { + display: "flex", + "justify-content": "flex-end", + gap: "8px", + padding: "4px 20px 20px", } +/** Promise-based, focus-contained alternatives to browser confirm/prompt/alert. */ export function confirmDialog( - dialog: Dialog, + dialog: DialogController, opts: { title: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }, ): Promise<boolean> { return new Promise((resolve) => { @@ -35,33 +26,18 @@ export function confirmDialog( resolve(value) dialog.close() } + dialog.show( () => ( - <div style={card()}> - <div style={{ padding: "18px 20px 8px" }}> - <div class="text-16-medium text-text-strong">{opts.title}</div> - <Show when={opts.message}> - <div - class="text-13-regular text-text-weak" - style={{ - "margin-top": "8px", - "max-width": "58ch", - "text-wrap": "pretty", - }} - > - {opts.message} - </div> - </Show> - </div> - <div - style={{ - display: "flex", - "justify-content": "flex-end", - gap: "8px", - padding: "12px 20px 18px", - }} - > - <Button size="normal" variant="secondary" onClick={() => done(false)}> + <ModalDialog + fit + transition + role={opts.danger ? "alertdialog" : "dialog"} + title={opts.title} + description={opts.message} + > + <div style={actions}> + <Button autofocus size="normal" variant="secondary" onClick={() => done(false)}> {opts.cancelLabel ?? "Cancel"} </Button> <Button @@ -73,15 +49,15 @@ export function confirmDialog( {opts.confirmLabel ?? "Confirm"} </Button> </div> - </div> + </ModalDialog> ), - { onClose: () => done(false), lite: true }, + { onClose: () => done(false) }, ) }) } export function promptDialog( - dialog: Dialog, + dialog: DialogController, opts: { title: string; message?: string; placeholder?: string; initial?: string; confirmLabel?: string }, ): Promise<string | null> { return new Promise((resolve) => { @@ -93,55 +69,26 @@ export function promptDialog( dialog.close() } const [value, setValue] = createSignal(opts.initial ?? "") + dialog.show( () => ( - <div style={card()}> - <div style={{ padding: "18px 20px 8px" }}> - <div class="text-16-medium text-text-strong">{opts.title}</div> - <Show when={opts.message}> - <div - class="text-13-regular text-text-weak" - style={{ - "margin-top": "8px", - "max-width": "58ch", - "text-wrap": "pretty", - }} - > - {opts.message} - </div> - </Show> - <input + <ModalDialog fit transition title={opts.title} description={opts.message}> + <div style={{ padding: "4px 20px 16px" }}> + <TextField autofocus + hideLabel + label={opts.title} value={value()} placeholder={opts.placeholder} - onInput={(e) => setValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") done(value()) - }} - style={{ - all: "unset", - "box-sizing": "border-box", - width: "100%", - "margin-top": "12px", - padding: "9px 10px", - border: "1px solid var(--color-border)", - "border-radius": "4px", - background: "var(--color-bg)", - color: "var(--color-text)", - "font-family": FONT_CODE, - "font-size": "12px", - "line-height": "18px", + onChange={setValue} + onKeyDown={(event: KeyboardEvent) => { + if (event.key !== "Enter") return + event.preventDefault() + done(value()) }} /> </div> - <div - style={{ - display: "flex", - "justify-content": "flex-end", - gap: "8px", - padding: "12px 20px 18px", - }} - > + <div style={actions}> <Button size="normal" variant="secondary" onClick={() => done(null)}> Cancel </Button> @@ -149,15 +96,15 @@ export function promptDialog( {opts.confirmLabel ?? "OK"} </Button> </div> - </div> + </ModalDialog> ), - { onClose: () => done(null), lite: true }, + { onClose: () => done(null) }, ) }) } export function alertDialog( - dialog: Dialog, + dialog: DialogController, opts: { title: string; message?: string; danger?: boolean }, ): Promise<void> { return new Promise((resolve) => { @@ -168,42 +115,24 @@ export function alertDialog( resolve() dialog.close() } + dialog.show( () => ( - <div style={card()}> - <div style={{ padding: "18px 20px 8px" }}> - <div - class="text-16-medium text-text-strong" - style={{ color: opts.danger ? "var(--color-error, #ef4444)" : undefined }} - > - {opts.title} - </div> - <Show when={opts.message}> - <div - class="text-13-regular text-text-weak" - style={{ - "margin-top": "8px", - "max-width": "58ch", - "text-wrap": "pretty", - }} - > - {opts.message} - </div> - </Show> - </div> - <div style={{ display: "flex", "justify-content": "flex-end", padding: "12px 20px 18px" }}> - <Button - size="normal" - variant="primary" - classList={{ "atlas-dialog__danger": opts.danger === true }} - onClick={done} - > + <ModalDialog + fit + transition + role={opts.danger ? "alertdialog" : "dialog"} + title={opts.title} + description={opts.message} + > + <div style={actions}> + <Button autofocus size="normal" variant="primary" onClick={done}> OK </Button> </div> - </div> + </ModalDialog> ), - { onClose: () => done(), lite: true }, + { onClose: done }, ) }) } diff --git a/frontend/workspace/src/atlas/execution-authority.test.ts b/frontend/workspace/src/atlas/execution-authority.test.ts index 5974fc8c..cb8d73e3 100644 --- a/frontend/workspace/src/atlas/execution-authority.test.ts +++ b/frontend/workspace/src/atlas/execution-authority.test.ts @@ -93,10 +93,51 @@ describe("frontend execution authority", () => { ) }) + test("submits only the canonical trust remediation returned by the server", async () => { + const calls: Array<{ url: URL; init?: RequestInit }> = [] + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: new URL(String(input)), init }) + return Response.json({ state: "trusted" }) + }) as typeof fetch + const request = createProjectRequest({ + baseUrl: () => "http://127.0.0.1:4096", + projectID: () => "prj_alpha", + directory: () => "/managed/project", + fetch: () => fetcher, + }) + const blocked = decision({ + allowed: false, + reason: "project_untrusted", + mode: "read_only", + remediation: { + code: "trust_project_required", + message: "Trust required", + method: "PUT", + path: "/project/prj_alpha/trust", + body: { trusted: true, root: "/managed/project" }, + }, + }) + + await createExecutionAuthorityAPI(request).trust(blocked) + + expect(calls).toHaveLength(1) + expect(calls[0].url.pathname).toBe("/project/prj_alpha/trust") + expect(calls[0].init?.method).toBe("PUT") + expect(JSON.parse(String(calls[0].init?.body))).toEqual({ trusted: true, root: "/managed/project" }) + await expect( + createExecutionAuthorityAPI(request).trust({ + ...blocked, + remediation: { ...blocked.remediation!, path: "/project/prj_other/trust" }, + }), + ).rejects.toThrow("valid project-trust action") + }) + test("fails closed without dereferencing a failed authority resource", () => { expect(hook).toContain("if (decision.error || decision.loading) return false") expect(hook).toContain("value.projectID === expected.projectID") expect(hook).toContain("value.sessionID === expected.sessionID") expect(hook).toContain("value.capability === expected.capability") + expect(hook).toContain("await api.trust(value)") + expect(hook).toContain("await controls.refetch()") }) }) diff --git a/frontend/workspace/src/atlas/execution-authority.ts b/frontend/workspace/src/atlas/execution-authority.ts index 5af41f43..a173e96a 100644 --- a/frontend/workspace/src/atlas/execution-authority.ts +++ b/frontend/workspace/src/atlas/execution-authority.ts @@ -85,6 +85,30 @@ export function createExecutionAuthorityAPI(request: ProjectRequest) { } return response.json() as Promise<ExecutionDecision> }, + async trust(input: ExecutionDecision): Promise<void> { + const remediation = input.remediation + const target = `/project/${encodeURIComponent(input.projectID)}/trust` + if ( + input.allowed || + input.reason !== "project_untrusted" || + !remediation || + remediation.method !== "PUT" || + remediation.path !== target || + remediation.body.trusted !== true || + !remediation.body.root + ) { + throw new Error("The server did not provide a valid project-trust action.") + } + const response = await request(target, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(remediation.body), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(detail || `${response.status} ${response.statusText}`) + } + }, } } diff --git a/frontend/workspace/src/atlas/file-drafts.test.ts b/frontend/workspace/src/atlas/file-drafts.test.ts new file mode 100644 index 00000000..d8d29da4 --- /dev/null +++ b/frontend/workspace/src/atlas/file-drafts.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { + discardFileDraft, + discardAllFileDrafts, + guardUnsavedFileDrafts, + hasUnsavedFileDrafts, + recoverFileDraft, + rememberFileDraft, +} from "./file-drafts" + +const directory = "/projects/alpha" +const path = "notes.md" + +beforeEach(discardAllFileDrafts) + +describe("file draft retention", () => { + test("restores an unsaved project draft after its view remounts", () => { + rememberFileDraft(directory, path, "edited locally", "saved on disk") + + expect(recoverFileDraft(directory, path, "saved on disk")).toBe("edited locally") + expect(hasUnsavedFileDrafts()).toBe(true) + + discardFileDraft(directory, path) + expect(recoverFileDraft(directory, path, "new disk value")).toBe("new disk value") + }) + + test("keeps clean files out of the draft cache", () => { + rememberFileDraft(directory, path, "same", "same") + + expect(hasUnsavedFileDrafts()).toBe(false) + }) + + test("blocks a browser unload while an unsaved draft exists", () => { + rememberFileDraft(directory, path, "edited", "saved") + const event = new Event("beforeunload", { cancelable: true }) as BeforeUnloadEvent + + guardUnsavedFileDrafts(event) + + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/frontend/workspace/src/atlas/file-drafts.ts b/frontend/workspace/src/atlas/file-drafts.ts new file mode 100644 index 00000000..10aaa0fe --- /dev/null +++ b/frontend/workspace/src/atlas/file-drafts.ts @@ -0,0 +1,45 @@ +type DraftWindow = Window & { + __openscienceFileDrafts?: Map<string, string> + __openscienceFileDraftGuard?: boolean +} + +const browser = typeof window === "undefined" ? undefined : (window as DraftWindow) +const drafts = browser ? (browser.__openscienceFileDrafts ??= new Map<string, string>()) : new Map<string, string>() + +const key = (directory: string, path: string) => `${directory}\n${path}` + +export function rememberFileDraft(directory: string, path: string, draft: string, saved: string) { + const id = key(directory, path) + if (draft === saved) { + drafts.delete(id) + return + } + drafts.set(id, draft) +} + +export function recoverFileDraft(directory: string, path: string, saved: string) { + return drafts.get(key(directory, path)) ?? saved +} + +export function discardFileDraft(directory: string, path: string) { + drafts.delete(key(directory, path)) +} + +export function discardAllFileDrafts() { + drafts.clear() +} + +export function hasUnsavedFileDrafts() { + return drafts.size > 0 +} + +export function guardUnsavedFileDrafts(event: BeforeUnloadEvent) { + if (!hasUnsavedFileDrafts()) return + event.preventDefault() + event.returnValue = "" +} + +if (browser && !browser.__openscienceFileDraftGuard) { + browser.__openscienceFileDraftGuard = true + browser.addEventListener("beforeunload", guardUnsavedFileDrafts) +} diff --git a/frontend/workspace/src/atlas/file-preview-render.test.ts b/frontend/workspace/src/atlas/file-preview-render.test.ts index 82603d85..16e4ba9e 100644 --- a/frontend/workspace/src/atlas/file-preview-render.test.ts +++ b/frontend/workspace/src/atlas/file-preview-render.test.ts @@ -13,6 +13,30 @@ describe("file preview markdown images", () => { expect(preview).toContain('<Markdown class="atlas-md" text={view.draft} resolveImage={image} />') }) + test("ordinary Markdown remains readable and editable through the existing save path", async () => { + const preview = await read("./FilePreview.tsx") + const css = await read("./FilePreview.css") + + expect(preview).toContain('sourceLabel={description().source && props.writable !== false ? "Edit" : undefined}') + expect(preview).toContain('classList={{ "is-prose-editor": kind() === "markdown" }}') + expect(preview).toContain("void save()") + expect(css).toContain(".atlas-file-source-editor.is-prose-editor") + expect(css).toContain("text-wrap: pretty") + expect(css).toContain("text-wrap: balance") + }) + + test("README alignment wrappers keep their inner Markdown parsed and sanitized", async () => { + const preview = await read("./FilePreview.tsx") + const css = await read("./FilePreview.css") + + expect(preview).toContain('import { splitAlignedMarkdown } from "@/atlas/FilePreviewMarkdown"') + expect(preview).toContain("data-align={lead().alignment}") + expect(preview).toContain("text={lead().text} resolveImage={image}") + expect(preview).toContain("text={rest()} resolveImage={image}") + expect(css).toContain('.atlas-file-document-lead[data-align="center"]') + expect(css).toContain("p:has(a > img)") + }) + test("chat markdown resolves images against the project root via the shared context", async () => { const layout = await read("../pages/directory-layout.tsx") @@ -24,7 +48,7 @@ describe("file preview markdown images", () => { test("the shared renderer rewrites image sources only after DOMPurify sanitization", async () => { const markdown = await read("../../../ui/src/components/markdown.tsx") - const sanitized = markdown.indexOf("const safe = sanitize(next)") + const sanitized = markdown.indexOf("(next) => sanitize(next)") const resolved = markdown.indexOf("if (resolve) resolveImages(temp, resolve)") expect(sanitized).toBeGreaterThan(-1) expect(resolved).toBeGreaterThan(sanitized) diff --git a/frontend/workspace/src/atlas/file-preview-surface.test.ts b/frontend/workspace/src/atlas/file-preview-surface.test.ts index 812dceb9..f9401578 100644 --- a/frontend/workspace/src/atlas/file-preview-surface.test.ts +++ b/frontend/workspace/src/atlas/file-preview-surface.test.ts @@ -4,17 +4,40 @@ const css = await Bun.file(new URL("./FilePreview.css", import.meta.url)).text() describe("file preview surface", () => { test("uses one compact operational toolbar", () => { - expect(css).toContain("min-height: 48px") + expect(css).toContain("min-height: 50px") expect(css).toContain("font-size: 14px") expect(css).toContain("font-size: 11px") expect(css).toContain("min-height: 32px") - expect(css).toContain("border-radius: 9px") + expect(css).toContain("border-radius: var(--atlas-radius-sm)") + expect(css).toContain("border-radius: var(--atlas-radius-xs)") }) test("keeps documents readable without presentation-scale spacing", () => { expect(css).toContain("width: min(100%, 820px)") expect(css).toContain("font-size: 15px") expect(css).toContain("font-size: 13px") - expect(css).toContain("padding: clamp(30px, 5vw, 52px)") + expect(css).toContain("padding: clamp(30px, 5cqi, 52px)") + }) + + test("keeps loading and segmented controls calm across host surfaces", () => { + expect(css).not.toContain("linear-gradient") + expect(css).not.toMatch(/border-radius:\s*(?:7|8|9|10)px/) + expect(css).toContain("background: var(--color-bg-subtle)") + }) + + test("keeps the preview chrome seamless and touch targets reachable", () => { + expect(css).toMatch(/\.atlas-file-toolbar\s*\{[^}]*border: 0;/s) + expect(css).toMatch(/\.atlas-file-modes\s*\{[^}]*border: 0;/s) + expect(css).toMatch(/\.atlas-file-button,\s*\.atlas-file-action\s*\{[^}]*border: 0;/s) + expect(css).toContain("@media (pointer: coarse)") + expect(css).toMatch(/@media \(pointer: coarse\)[\s\S]*min-height: 44px/) + }) + + test("responds to the resizable pane and gives complex previews their own scroller", () => { + expect(css).toContain("container: atlas-file-view / inline-size") + expect(css).toContain("@container atlas-file-view (max-width: 760px)") + expect(css).toMatch(/\.atlas-file-scroll\.is-managed-scroll,[\s\S]*overflow: hidden/s) + expect(css).toMatch(/\.atlas-file-pdf\s*\{[^}]*flex: 1/s) + expect(css).toMatch(/\.atlas-file-pdf \.pdf-viewer\s*\{[^}]*height: 100%/s) }) }) diff --git a/frontend/workspace/src/atlas/file-session-access.test.ts b/frontend/workspace/src/atlas/file-session-access.test.ts index 3380f3f9..0b25c86a 100644 --- a/frontend/workspace/src/atlas/file-session-access.test.ts +++ b/frontend/workspace/src/atlas/file-session-access.test.ts @@ -20,7 +20,8 @@ describe("session-scoped file requests", () => { expect(pane).toContain("if (session) query.sessionID = session") expect(pane).toContain('transport("/file", undefined, query)') expect(preview).toContain('const sessionID = () => (params.id && params.id !== "new" ? params.id : undefined)') - expect(preview).toContain("sdk.client.file.read({ path, sessionID: sessionID() })") + expect(preview).toContain("const activeSession = untrack(sessionID)") + expect(preview).toContain("sdk.client.file.read({ path, sessionID: activeSession })") expect(preview).toContain("body: JSON.stringify({ path, content, sessionID: session })") expect(preview).toContain("sessionID: session,") }) @@ -37,4 +38,14 @@ describe("session-scoped file requests", () => { expect(manuscript).toContain('sdk.request("/file/content", undefined, query(path))') expect(manuscript).toContain('sdk.request.url("/file/raw", query(path))') }) + + test("keeps project-scoped drafts stable across session navigation and blocks stale artifact saves", () => { + const preview = read("./FilePreview.tsx") + + expect(preview).toContain("untrack(sessionID)") + expect(preview).toContain("recoverFileDraft(dir, path, text)") + expect(preview).toContain("rememberFileDraft(directory(), props.path, view.draft, view.saved)") + expect(preview).toContain("reconcileSavedDraft(view.draft, content, next)") + expect(preview).toMatch(/const artifact = async \(\) => \{[\s\S]*if \(dirty\(\)\)[\s\S]*save file first/) + }) }) diff --git a/frontend/workspace/src/atlas/file-viewer.test.ts b/frontend/workspace/src/atlas/file-viewer.test.ts index d9f5f855..9a8478cb 100644 --- a/frontend/workspace/src/atlas/file-viewer.test.ts +++ b/frontend/workspace/src/atlas/file-viewer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test" -import { artifactControl, describeFile, readFile, sourceViews, toolbarControls } from "./file-viewer" +import { + artifactControl, + describeFile, + readFile, + reconcileSavedDraft, + sourceViews, + toolbarControls, +} from "./file-viewer" describe("file viewer capabilities", () => { test("describes rendered documents and data with plain file types", () => { @@ -83,17 +90,33 @@ describe("file viewer capabilities", () => { }) test("offers Save as artifact only when a session is in scope", () => { - expect(artifactControl({ session: false, busy: false })).toBeUndefined() - expect(artifactControl({ session: true, busy: false })).toEqual({ + expect(artifactControl({ session: false, busy: false, dirty: false })).toBeUndefined() + expect(artifactControl({ session: true, busy: false, dirty: false })).toEqual({ id: "artifact", label: "Save as artifact", disabled: false, }) - expect(artifactControl({ session: true, busy: true })).toEqual({ + expect(artifactControl({ session: true, busy: true, dirty: false })).toEqual({ id: "artifact", label: "Saving artifact…", disabled: true, }) + expect(artifactControl({ session: true, busy: false, dirty: true })).toEqual({ + id: "artifact", + label: "Save file first", + disabled: true, + }) + }) + + test("preserves edits typed while an earlier save is in flight", () => { + expect(reconcileSavedDraft("second edit", "first edit", "first edit")).toEqual({ + draft: "second edit", + saved: "first edit", + }) + expect(reconcileSavedDraft("first edit", "first edit", "normalized first edit")).toEqual({ + draft: "normalized first edit", + saved: "normalized first edit", + }) }) test("does not build source or copy controls for unsupported binaries", () => { diff --git a/frontend/workspace/src/atlas/file-viewer.ts b/frontend/workspace/src/atlas/file-viewer.ts index ab69f5f4..c09877b0 100644 --- a/frontend/workspace/src/atlas/file-viewer.ts +++ b/frontend/workspace/src/atlas/file-viewer.ts @@ -111,15 +111,20 @@ export function toolbarControls(input: { export function artifactControl(input: { session: boolean busy: boolean + dirty: boolean }): { id: "artifact"; label: string; disabled: boolean } | undefined { if (!input.session) return return { id: "artifact", - label: input.busy ? "Saving artifact…" : "Save as artifact", - disabled: input.busy, + label: input.busy ? "Saving artifact…" : input.dirty ? "Save file first" : "Save as artifact", + disabled: input.busy || input.dirty, } } +export function reconcileSavedDraft(current: string, submitted: string, saved: string) { + return { draft: current === submitted ? saved : current, saved } +} + export async function readFile(reader: () => Promise<FileData>): Promise<{ data?: FileData; error?: Error }> { return reader().then( (data) => ({ data }), diff --git a/frontend/workspace/src/atlas/files/ArtifactCard.test.ts b/frontend/workspace/src/atlas/files/ArtifactCard.test.ts index f6b30817..c3c77214 100644 --- a/frontend/workspace/src/atlas/files/ArtifactCard.test.ts +++ b/frontend/workspace/src/atlas/files/ArtifactCard.test.ts @@ -84,7 +84,7 @@ describe("artifact card", () => { expect(open).not.toBeNull() expect(menu).not.toBeNull() expect(open!.contains(menu!)).toBe(false) - expect(open!.getAttribute("aria-label")).toBe("Open train.py") + expect(open!.getAttribute("aria-label")).toBe("Open train.py, version 1") expect(menu!.getAttribute("aria-label")).toBe("Actions for train.py") }) @@ -167,6 +167,20 @@ describe("artifact card", () => { expect(sized.querySelector("[data-card-meta]")?.textContent).toContain("100 B") }) + test("shows the current immutable version without adding a decorative badge", () => { + const item = artifact({ filename: "report.md" }) as unknown as { + versionCount: number + current: { version: number } + } + item.versionCount = 4 + item.current.version = 4 + const host = mount(() => subject.ArtifactCard(props({ artifact: item }) as never)) + + expect(host.querySelector("[data-card-version]")?.textContent).toBe("Version 4 of 4") + expect(host.querySelector("[data-card-open]")?.getAttribute("aria-label")).toContain("version 4 of 4") + expect(host.querySelector("[data-card-version] svg")).toBeNull() + }) + test("carries its layout so the grid and list can style one component", () => { const host = mount(() => subject.ArtifactCard(props({ layout: "list" }) as never)) diff --git a/frontend/workspace/src/atlas/files/ArtifactCard.tsx b/frontend/workspace/src/atlas/files/ArtifactCard.tsx index 50e86ed1..c8dc8ff2 100644 --- a/frontend/workspace/src/atlas/files/ArtifactCard.tsx +++ b/frontend/workspace/src/atlas/files/ArtifactCard.tsx @@ -3,6 +3,7 @@ import type { StoredArtifact } from "@/artifacts/store" import { ArtifactThumb, type ThumbProps } from "./ArtifactThumb" import { bytes } from "./bytes" import { ago } from "./ago" +import { IconDownload, IconEdit, IconExpand, IconMoreH, IconTrash } from "@/atlas/shared/Icon" export interface CardProps extends ThumbProps { layout: "grid" | "list" @@ -51,10 +52,10 @@ export function ArtifactCard(props: CardProps): JSX.Element { }) } - const meta = () => - props.sizes - ? `${ago(props.artifact.createdAt)} · ${bytes(props.artifact.current.size)}` - : ago(props.artifact.createdAt) + const version = () => + props.artifact.versionCount > 1 + ? `Version ${props.artifact.current.version} of ${props.artifact.versionCount}` + : `Version ${props.artifact.current.version}` const act = (run: (artifact: StoredArtifact) => void) => { setOpen(false) @@ -62,7 +63,7 @@ export function ArtifactCard(props: CardProps): JSX.Element { } return ( - <div class="artifact-card" data-layout={props.layout}> + <div class="artifact-card" data-layout={props.layout} data-capture-quality={props.artifact.current.captureQuality}> {/* The actions trigger is a sibling of the open control, never nested inside it: a control within a control is invalid, and its label folds into the outer control's accessible name. 53331773 and f25d7f10 each @@ -71,14 +72,28 @@ export function ArtifactCard(props: CardProps): JSX.Element { type="button" class="artifact-card__open" data-card-open - aria-label={`Open ${props.artifact.title}`} + aria-label={`Open ${props.artifact.title}, ${version().toLowerCase()}`} onClick={() => props.onOpen(props.artifact)} > <ArtifactThumb artifact={props.artifact} url={props.url} read={props.read} highlight={props.highlight} /> <span class="artifact-card__label"> <span class="artifact-card__name">{props.artifact.title}</span> <span class="artifact-card__sub" data-card-meta> - {meta()} + <span data-card-saved title={new Date(props.artifact.current.createdAt).toLocaleString()}> + Saved {ago(props.artifact.current.createdAt)} + </span> + <span class="artifact-card__meta-separator" aria-hidden="true"> + · + </span> + <span class="artifact-card__version" data-card-version> + {version()} + </span> + <Show when={props.sizes}> + <span class="artifact-card__meta-separator" aria-hidden="true"> + · + </span> + <span data-card-size>{bytes(props.artifact.current.size)}</span> + </Show> </span> </span> </button> @@ -92,7 +107,7 @@ export function ArtifactCard(props: CardProps): JSX.Element { aria-expanded={open()} onClick={() => setOpen(!open())} > - ⋮ + <IconMoreH size={15} strokeWidth={1.6} /> </button> <Show when={open()}> @@ -104,6 +119,7 @@ export function ArtifactCard(props: CardProps): JSX.Element { /> <div class="artifact-menu" role="menu" ref={follow}> <button type="button" role="menuitem" data-action="open" onClick={() => act(props.onOpen)}> + <IconExpand size={14} strokeWidth={1.5} /> Open in tab </button> <a @@ -113,9 +129,11 @@ export function ArtifactCard(props: CardProps): JSX.Element { download={props.artifact.current.filename} onClick={() => setOpen(false)} > + <IconDownload size={14} strokeWidth={1.5} /> Download </a> <button type="button" role="menuitem" data-action="rename" onClick={() => act(props.onRename)}> + <IconEdit size={14} strokeWidth={1.5} /> Rename… </button> <button @@ -125,6 +143,7 @@ export function ArtifactCard(props: CardProps): JSX.Element { class="artifact-menu__danger" onClick={() => act(props.onTrash)} > + <IconTrash size={14} strokeWidth={1.5} /> Move to trash </button> </div> diff --git a/frontend/workspace/src/atlas/files/ArtifactGrid.test.ts b/frontend/workspace/src/atlas/files/ArtifactGrid.test.ts index 30b4a604..3e1d740c 100644 --- a/frontend/workspace/src/atlas/files/ArtifactGrid.test.ts +++ b/frontend/workspace/src/atlas/files/ArtifactGrid.test.ts @@ -93,12 +93,13 @@ describe("artifact grid", () => { const host = mount(() => subject.ArtifactGrid(props() as never)) expect(host.querySelectorAll("[data-artifact-group]")).toHaveLength(2) - host.querySelector<HTMLButtonElement>("[data-artifact-sort]")!.click() + host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() + host.querySelector<HTMLButtonElement>("[data-artifact-sort='name']")!.click() expect(host.querySelectorAll("[data-artifact-group]")).toHaveLength(0) expect([...host.querySelectorAll("[data-card-open]")].map((node) => node.getAttribute("aria-label"))).toEqual([ - "Open a.py", - "Open b.py", + "Open a.py, version 1", + "Open b.py, version 1", ]) }) @@ -114,16 +115,18 @@ describe("artifact grid", () => { test("switches layout and says so", () => { const host = mount(() => subject.ArtifactGrid(props() as never)) + host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() host.querySelector<HTMLButtonElement>("[data-artifact-layout='list']")!.click() expect(host.querySelector("[data-artifact-list]")).not.toBeNull() expect(host.querySelector("[data-artifact-grid]")).toBeNull() - expect(host.querySelector("[data-artifact-layout='list']")?.getAttribute("aria-pressed")).toBe("true") + expect(host.querySelector("[data-artifact-layout='list']")?.getAttribute("aria-checked")).toBe("true") }) test("remembers sort and layout across a remount", () => { const first = mount(() => subject.ArtifactGrid(props() as never)) - first.querySelector<HTMLButtonElement>("[data-artifact-sort]")!.click() + first.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() + first.querySelector<HTMLButtonElement>("[data-artifact-sort='name']")!.click() first.querySelector<HTMLButtonElement>("[data-artifact-layout='list']")!.click() cleanups.splice(0).forEach((fn) => fn()) @@ -131,7 +134,8 @@ describe("artifact grid", () => { const second = mount(() => subject.ArtifactGrid(props() as never)) - expect(second.querySelector("[data-artifact-sort]")?.textContent).toContain("Name") + second.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() + expect(second.querySelector("[data-artifact-sort='name']")?.getAttribute("aria-checked")).toBe("true") expect(second.querySelector("[data-artifact-list]")).not.toBeNull() }) @@ -150,24 +154,61 @@ describe("artifact grid", () => { test("says a search matched nothing rather than claiming none are saved", () => { const host = mount(() => subject.ArtifactGrid(props({ artifacts: [], filtered: true }) as never)) - expect(host.textContent).toContain("No artifacts match this search.") - expect(host.textContent).not.toContain("No artifacts saved yet.") + expect(host.textContent).toContain("No matching artifacts") + expect(host.textContent).toContain("clear the search") + expect(host.textContent).not.toContain("No saved artifacts yet") }) test("names the empty state for artifacts, not folders", () => { const host = mount(() => subject.ArtifactGrid(props({ artifacts: [] }) as never)) - expect(host.textContent).toContain("No artifacts saved yet.") + expect(host.textContent).toContain("No saved artifacts yet") + expect(host.textContent).toContain("versions intact") expect(host.textContent).not.toContain("folder") expect(host.querySelector("[data-artifact-count]")?.textContent).toBe("0 artifacts") }) - // Two menus on one surface must not look like the same control. - test("keeps the preferences trigger distinct from a card's", () => { + // One plain-language view control reduces toolbar icons and cannot be + // confused with the ellipsis that acts on an individual artifact. + test("keeps the view trigger distinct from a card's action menu", () => { + const host = mount(() => subject.ArtifactGrid(props() as never)) + + expect(host.querySelector("[data-artifact-prefs] svg")).not.toBeNull() + expect(host.querySelector("[data-card-menu] svg")).not.toBeNull() + expect(host.querySelector("[data-artifact-prefs]")?.textContent).toContain("View") + expect(host.querySelector("[data-card-menu]")?.textContent?.trim()).toBe("") + }) + + test("groups sort, layout, and size choices behind one view control", () => { const host = mount(() => subject.ArtifactGrid(props() as never)) - expect(host.querySelector("[data-artifact-prefs]")?.textContent).not.toBe("⋮") - expect(host.querySelector("[data-card-menu]")?.textContent).toBe("⋮") + expect(host.querySelector("[data-artifact-sort]")).toBeNull() + expect(host.querySelector("[data-artifact-layout]")).toBeNull() + host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() + + expect(host.querySelectorAll("[data-artifact-sort]")).toHaveLength(2) + expect(host.querySelectorAll("[data-artifact-layout]")).toHaveLength(2) + expect(host.querySelector("[data-pref='sizes']")).not.toBeNull() + }) + + test("moves focus through view options and Escape restores the trigger", async () => { + const host = mount(() => subject.ArtifactGrid(props() as never)) + const trigger = host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")! + + trigger.click() + await Promise.resolve() + const options = [...host.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]')] + expect(document.activeElement).toBe(options[0]) + + options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })) + expect(document.activeElement).toBe(options[1]) + options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })) + expect(document.activeElement).toBe(options.at(-1)!) + + document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) + await Promise.resolve() + expect(host.querySelector("[role='menu']")).toBeNull() + expect(document.activeElement).toBe(trigger) }) test("pins the current session ahead of a newer one", () => { diff --git a/frontend/workspace/src/atlas/files/ArtifactGrid.tsx b/frontend/workspace/src/atlas/files/ArtifactGrid.tsx index 19c98d66..27af3fc9 100644 --- a/frontend/workspace/src/atlas/files/ArtifactGrid.tsx +++ b/frontend/workspace/src/atlas/files/ArtifactGrid.tsx @@ -5,6 +5,8 @@ import type { ThumbProps } from "./ArtifactThumb" import { groupBySession, sortArtifacts, type Group } from "./artifact-groups" import { readView, writeView, type View } from "./artifact-view" import { age } from "./ago" +import { IconChevronDown } from "@/atlas/shared/Icon" +import "./file-items.css" export interface GridProps extends Omit<ThumbProps, "artifact"> { artifacts: StoredArtifact[] @@ -12,6 +14,9 @@ export interface GridProps extends Omit<ThumbProps, "artifact"> { currentSession: string | undefined /** Set while the pane's search box is filtering, so an empty grid can say why. */ filtered?: boolean + /** Empty data during loading or a failed request is not a true empty state. */ + loading?: boolean + unavailable?: boolean onOpen: (artifact: StoredArtifact) => void onRename: (artifact: StoredArtifact) => void onTrash: (artifact: StoredArtifact) => void @@ -20,6 +25,21 @@ export interface GridProps extends Omit<ThumbProps, "artifact"> { export function ArtifactGrid(props: GridProps): JSX.Element { const [view, setView] = createSignal<View>(readView()) const [prefs, setPrefs] = createSignal(false) + const refs: { trigger?: HTMLButtonElement; menu?: HTMLDivElement } = {} + const options = () => Array.from(refs.menu?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]') ?? []) + const focusOption = (option: HTMLButtonElement | undefined) => { + if (!option) return + options().forEach((candidate) => (candidate.tabIndex = candidate === option ? 0 : -1)) + option.focus() + } + const openPrefs = () => { + setPrefs(true) + queueMicrotask(() => focusOption(options()[0])) + } + const closePrefs = (restoreFocus = false) => { + setPrefs(false) + if (restoreFocus) queueMicrotask(() => refs.trigger?.focus()) + } // Spread rather than mutate: readView() hands back the shared DEFAULT_VIEW // object itself whenever storage is empty or invalid, and writing through it @@ -63,78 +83,148 @@ export function ArtifactGrid(props: GridProps): JSX.Element { return ( <div class="artifact-surface"> <div class="artifact-toolbar"> - <span class="artifact-toolbar__count" data-artifact-count> - {props.artifacts.length} {props.artifacts.length === 1 ? "artifact" : "artifacts"} + <span class="artifact-toolbar__primary"> + <span class="artifact-toolbar__count" data-artifact-count> + {props.artifacts.length} {props.artifacts.length === 1 ? "artifact" : "artifacts"} + </span> + <span class="artifact-toolbar__hint">Versioned deliverables</span> </span> - <button - type="button" - class="artifact-toolbar__sort" - data-artifact-sort - onClick={() => apply({ sort: sort() === "created" ? "name" : "created" })} - > - {sort() === "created" ? "Created ↓" : "Name ↓"} - </button> + {/* Sorting, layout and the optional size column are one mental model: + how this catalog is presented. Keeping them behind one familiar + text control leaves a 320px pane usable without deleting choices. */} + <span class="artifact-toolbar__controls"> + <button + ref={(element) => { + refs.trigger = element + }} + type="button" + class="artifact-toolbar__prefs" + data-artifact-prefs + aria-label="Artifact view options" + aria-expanded={prefs()} + onClick={() => (prefs() ? closePrefs() : openPrefs())} + > + View + <IconChevronDown size={12} strokeWidth={1.5} /> + </button> + + <Show when={prefs()}> + <button + type="button" + class="artifact-menu__scrim" + aria-label="Dismiss artifact view options" + onClick={() => closePrefs(true)} + /> + {/* The backend does not expose the artifact-store path, so this menu + contains presentation choices only. No guessed location or + unsupported storage mode is presented as fact. */} + <div + ref={(element) => { + refs.menu = element + }} + class="artifact-menu artifact-menu--prefs" + role="menu" + aria-label="Artifact view options" + onKeyDown={(event) => { + if (event.key === "Escape" || event.key === "Tab") { + event.preventDefault() + closePrefs(true) + return + } + const items = options() + const current = items.indexOf(document.activeElement as HTMLButtonElement) + const target = + event.key === "Home" + ? items[0] + : event.key === "End" + ? items.at(-1) + : event.key === "ArrowDown" + ? items[(current + 1 + items.length) % items.length] + : event.key === "ArrowUp" + ? items[(current - 1 + items.length) % items.length] + : undefined + if (!target) return + event.preventDefault() + focusOption(target) + }} + > + <span class="artifact-menu__section" role="presentation"> + Sort + </span> + <For each={["created", "name"] as const}> + {(option) => ( + <button + type="button" + role="menuitemradio" + tabindex="-1" + data-artifact-sort={option} + aria-checked={sort() === option} + onClick={() => apply({ sort: option })} + > + <span aria-hidden="true" class="artifact-menu__check"> + {sort() === option ? "✓" : ""} + </span> + {option === "created" ? "Recently saved" : "Name A–Z"} + </button> + )} + </For> - <span class="artifact-toolbar__layout"> - <For each={["grid", "list"] as const}> - {(option) => ( + <span class="artifact-menu__section" role="presentation"> + Layout + </span> + <For each={["grid", "list"] as const}> + {(option) => ( + <button + type="button" + role="menuitemradio" + tabindex="-1" + data-artifact-layout={option} + aria-checked={layout() === option} + onClick={() => apply({ layout: option })} + > + <span aria-hidden="true" class="artifact-menu__check"> + {layout() === option ? "✓" : ""} + </span> + {option === "grid" ? "Grid" : "List"} + </button> + )} + </For> + + <span class="artifact-menu__separator" role="separator" /> <button type="button" - data-artifact-layout={option} - aria-label={option === "grid" ? "Grid" : "List"} - aria-pressed={layout() === option} - onClick={() => apply({ layout: option })} + role="menuitemcheckbox" + tabindex="-1" + aria-checked={sizes()} + data-pref="sizes" + onClick={() => apply({ sizes: !sizes() })} > - {option === "grid" ? "▦" : "≡"} + <span aria-hidden="true" class="artifact-menu__check"> + {sizes() ? "✓" : ""} + </span> + Show file sizes </button> - )} - </For> + </div> + </Show> </span> - - {/* Its own glyph, not the card's: two identical triggers a few pixels - apart meaning different things is the cost of having both menus. */} - <button - type="button" - class="artifact-toolbar__prefs" - data-artifact-prefs - aria-label="View options" - aria-expanded={prefs()} - onClick={() => setPrefs(!prefs())} - > - ⚙ - </button> - - <Show when={prefs()}> - <button - type="button" - class="artifact-menu__scrim" - aria-label="Dismiss view options" - onClick={() => setPrefs(false)} - /> - {/* "Copy store path" was specified here and cut: the store lives under - Global.Path.data, and the server's /path payload reports home, - state, config, worktree and directory but never data, so any path - this menu offered would be a guess. It needs a backend field first. */} - <div class="artifact-menu artifact-menu--prefs" role="menu"> - <button type="button" role="menuitem" data-pref="sizes" onClick={() => apply({ sizes: !sizes() })}> - <span aria-hidden="true" class="artifact-menu__check"> - {sizes() ? "✓" : ""} - </span> - Show file sizes - </button> - </div> - </Show> </div> <Show when={props.artifacts.length > 0} fallback={ - // "No artifacts saved yet." is false when a search simply matched - // nothing, and the count beside it already says 0. - <div class="files-empty"> - {props.filtered ? "No artifacts match this search." : "No artifacts saved yet."} - </div> + <Show when={!props.loading && !props.unavailable}> + {/* "No artifacts saved yet." is false when a search simply matched + nothing, and the count beside it already says 0. */} + <div class="files-empty files-empty--artifacts" data-artifact-empty> + <strong>{props.filtered ? "No matching artifacts" : "No saved artifacts yet"}</strong> + <span> + {props.filtered + ? "Try a different name or clear the search." + : "Deliverables saved by a session appear here with their versions intact."} + </span> + </div> + </Show> } > <For each={groups()}> diff --git a/frontend/workspace/src/atlas/files/FileTable.test.ts b/frontend/workspace/src/atlas/files/FileTable.test.ts index 314d9a12..81ebb261 100644 --- a/frontend/workspace/src/atlas/files/FileTable.test.ts +++ b/frontend/workspace/src/atlas/files/FileTable.test.ts @@ -52,11 +52,11 @@ describe("file table", () => { ]) }) - test("shows a dash for a directory and a human size for a file", () => { + test("leaves folder size quiet and shows human sizes for files", () => { const host = mount(() => subject.FileTable({ rows: ROWS, depth: 0, onOpen: () => {}, onUp: () => {} })) const sizes = [...host.querySelectorAll("[data-file-size]")].map((n) => n.textContent) - expect(sizes[0]).toBe("—") + expect(sizes[0]).toBe("") expect(sizes).toContain("2.5 KB") expect(sizes).toContain("21 KB") }) @@ -67,6 +67,14 @@ describe("file table", () => { expect(host.querySelector("[data-file-age]")).toBeNull() }) + test("uses a familiar Name and Size header with semantic file icons", () => { + const host = mount(() => subject.FileTable({ rows: ROWS, depth: 0, onOpen: () => {}, onUp: () => {} })) + + expect(host.querySelector(".files-table__header")?.textContent).toContain("Name") + expect(host.querySelector(".files-table__header")?.textContent).toContain("Size") + expect(host.querySelectorAll("[data-file-row] .files-row__glyph svg")).toHaveLength(ROWS.length) + }) + test("offers a parent row only below the root, and reports both actions", () => { const opened: string[] = [] let ups = 0 @@ -82,6 +90,7 @@ describe("file table", () => { deep.querySelector<HTMLButtonElement>('[data-file-row="train_lr.py"]')?.click() expect(ups).toBe(1) + expect(deep.querySelector("[data-file-up]")?.textContent).toContain("Parent folder") expect(opened).toEqual(["train_lr.py"]) }) @@ -90,4 +99,26 @@ describe("file table", () => { expect(host.textContent).toContain("This folder is empty") }) + + test("distinguishes a filtered result from a truly empty folder", () => { + const host = mount(() => + subject.FileTable({ rows: [], depth: 0, filtered: true, onOpen: () => {}, onUp: () => {} }), + ) + + expect(host.textContent).toContain("No matching files") + expect(host.textContent).toContain("clear the search") + expect(host.textContent).not.toContain("This folder is empty") + }) + + test("does not claim the folder is empty while loading or unavailable", () => { + const loading = mount(() => + subject.FileTable({ rows: [], depth: 0, loading: true, onOpen: () => {}, onUp: () => {} }), + ) + const unavailable = mount(() => + subject.FileTable({ rows: [], depth: 0, unavailable: true, onOpen: () => {}, onUp: () => {} }), + ) + + expect(loading.textContent).not.toContain("This folder is empty.") + expect(unavailable.textContent).not.toContain("This folder is empty.") + }) }) diff --git a/frontend/workspace/src/atlas/files/FileTable.tsx b/frontend/workspace/src/atlas/files/FileTable.tsx index 5cdd9525..590328a3 100644 --- a/frontend/workspace/src/atlas/files/FileTable.tsx +++ b/frontend/workspace/src/atlas/files/FileTable.tsx @@ -1,4 +1,5 @@ import { For, Show, createMemo, type JSX } from "solid-js" +import { IconArrowUp, IconFile, IconFolder } from "@/atlas/shared/Icon" import { bytes } from "./bytes" export interface FileRow { @@ -19,6 +20,12 @@ export function FileTable(props: { depth: number onOpen: (row: FileRow) => void onUp: () => void + /** The visible rows are a search result, not the folder's full contents. */ + filtered?: boolean + /** Suppresses a false empty state while the first listing is in flight. */ + loading?: boolean + /** Suppresses a false empty state when the listing could not be read. */ + unavailable?: boolean /** * A listing is in flight. The rows on screen still describe the folder being * left, so they are shown but not clickable: clicking one would append its @@ -35,23 +42,44 @@ export function FileTable(props: { return ( <div class="files-table" classList={{ "files-table--busy": props.busy }} aria-busy={props.busy}> + <Show when={sorted().length > 0}> + <div class="files-table__header" aria-hidden="true"> + <span /> + <span>Name</span> + <span>Size</span> + </div> + </Show> + <Show when={props.depth > 0}> <button type="button" class="files-row files-row--up" data-file-up + aria-label="Go to parent folder" disabled={props.busy} onClick={() => props.onUp()} > <span class="files-row__glyph" aria-hidden="true"> - ↑ + <IconArrowUp size={14} strokeWidth={1.5} /> </span> - <span class="files-row__name">..</span> + <span class="files-row__name">Parent folder</span> <span class="files-row__size" /> </button> </Show> - <Show when={sorted().length > 0} fallback={<div class="files-empty">This folder is empty.</div>}> + <Show + when={sorted().length > 0} + fallback={ + <Show when={!props.loading && !props.unavailable}> + <div class="files-empty files-empty--folder" data-folder-empty> + <strong>{props.filtered ? "No matching files" : "This folder is empty"}</strong> + <span> + {props.filtered ? "Try a different name or clear the search." : "This location contains no files."} + </span> + </div> + </Show> + } + > <For each={sorted()}> {(row) => ( <button @@ -59,17 +87,24 @@ export function FileTable(props: { class="files-row" classList={{ "files-row--ignored": row.ignored }} data-file-row={row.name} + data-file-kind={row.type} + aria-label={`${row.type === "directory" ? "Open folder" : "Open file"} ${row.name}`} + title={row.ignored ? `${row.name} is ignored by the project` : row.name} disabled={props.busy} onClick={() => props.onOpen(row)} > <span class="files-row__glyph" aria-hidden="true"> - {row.type === "directory" ? "▭" : "▫"} + {row.type === "directory" ? ( + <IconFolder size={15} strokeWidth={1.45} /> + ) : ( + <IconFile size={15} strokeWidth={1.45} /> + )} </span> <span class="files-row__name" data-file-name> {row.name} </span> <span class="files-row__size" data-file-size> - {row.type === "directory" ? "—" : bytes(row.size)} + {row.type === "directory" ? "" : bytes(row.size)} </span> </button> )} diff --git a/frontend/workspace/src/atlas/files/FileTabs.test.ts b/frontend/workspace/src/atlas/files/FileTabs.test.ts deleted file mode 100644 index ae65cd39..00000000 --- a/frontend/workspace/src/atlas/files/FileTabs.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { fileURLToPath } from "node:url" -import type { JSX } from "solid-js" -import { createServer } from "vite" -import solid from "vite-plugin-solid" - -const server = await createServer({ - root: fileURLToPath(new URL("../../..", import.meta.url)), - mode: "production", - logLevel: "silent", - plugins: [solid({ ssr: false, dev: false })], - server: { middlewareMode: true }, - appType: "custom", - resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, - ssr: { noExternal: true, resolve: { conditions: ["browser", "production"] } }, -}) -const [subject, web] = await Promise.all([ - server.ssrLoadModule("/src/atlas/files/FileTabs.tsx") as Promise<typeof import("./FileTabs")>, - server.ssrLoadModule("solid-js/web") as Promise<typeof import("solid-js/web")>, -]) -const cleanups: Array<() => void> = [] - -afterAll(() => server.close()) -afterEach(() => { - cleanups.splice(0).forEach((fn) => fn()) - document.body.replaceChildren() -}) - -const mount = (view: () => JSX.Element) => { - const host = document.createElement("div") - document.body.append(host) - cleanups.push(web.render(view, host)) - return host -} - -describe("file tabs", () => { - test("does not add a redundant Browse tab above the browser", () => { - const host = mount(() => - subject.FileTabs({ open: ["train_lr.py"], active: undefined, onSelect: () => {}, onClose: () => {} }), - ) - - expect(host.querySelectorAll("[data-tab]")).toHaveLength(1) - expect(host.querySelector("[data-tab-label]")?.textContent).toBe("train_lr.py") - expect(host.querySelector('[data-tab="train_lr.py"]')?.getAttribute("aria-selected")).toBe("false") - }) - - test("hides the empty tab strip while browsing before a file is opened", () => { - const host = mount(() => subject.FileTabs({ open: [], active: undefined, onSelect: () => {}, onClose: () => {} })) - - expect(host.querySelector('[role="tablist"]')).toBeNull() - }) - - test("keeps a real file named files selectable", () => { - const picked: string[] = [] - const host = mount(() => - subject.FileTabs({ open: ["files"], active: "files", onSelect: (id) => picked.push(id), onClose: () => {} }), - ) - - host.querySelector<HTMLButtonElement>('[data-tab="files"]')?.click() - - expect(picked).toEqual(["files"]) - }) - - test("selecting and closing report separately, and closing does not select", () => { - const picked: string[] = [] - const closed: string[] = [] - const host = mount(() => - subject.FileTabs({ - open: ["train_lr.py"], - active: undefined, - onSelect: (id) => picked.push(id), - onClose: (id) => closed.push(id), - }), - ) - - host.querySelector<HTMLButtonElement>('[data-tab="train_lr.py"]')?.click() - host.querySelector<HTMLButtonElement>('[data-tab-close="train_lr.py"]')?.click() - - expect(picked).toEqual(["train_lr.py"]) - expect(closed).toEqual(["train_lr.py"]) - }) - - test("reorders file tabs with the keyboard", () => { - const moved: Array<[string, number]> = [] - const host = mount(() => - subject.FileTabs({ - open: ["train.py", "README.md"], - active: "train.py", - onSelect: () => {}, - onClose: () => {}, - onReorder: (id, to) => moved.push([id, to]), - }), - ) - const tab = host.querySelector<HTMLButtonElement>('[data-tab="train.py"]')! - - tab.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", altKey: true, bubbles: true })) - - expect(moved).toEqual([["train.py", 1]]) - }) - - test("keeps close a sibling of the tab it closes, not a control inside it", () => { - // Nested interactive content is invalid, and the nested label folds into the - // parent's accessible name: the tab would announce as "train_lr.py Close - // train_lr.py", one control with two purposes. - const host = mount(() => - subject.FileTabs({ open: ["train_lr.py"], active: undefined, onSelect: () => {}, onClose: () => {} }), - ) - const tab = host.querySelector<HTMLElement>('[data-tab="train_lr.py"]')! - const close = host.querySelector<HTMLElement>('[data-tab-close="train_lr.py"]')! - - expect(tab.contains(close)).toBe(false) - expect(tab.querySelector("button, [role='button']")).toBeNull() - expect(close.tagName).toBe("BUTTON") - expect(close.getAttribute("tabindex")).toBeNull() - expect(close.getAttribute("aria-label")).toBe("Close train_lr.py") - }) - - test("truncates a long filename in the middle so the extension survives", () => { - const host = mount(() => - subject.FileTabs({ - open: ["modal_env_parser_test.ipynb"], - active: "modal_env_parser_test.ipynb", - onSelect: () => {}, - onClose: () => {}, - }), - ) - const label = host.querySelector('[data-tab="modal_env_parser_test.ipynb"] [data-tab-label]')?.textContent ?? "" - - expect(label).toContain("…") - expect(label.endsWith(".ipynb")).toBe(true) - expect(label.length).toBeLessThan("modal_env_parser_test.ipynb".length) - }) -}) diff --git a/frontend/workspace/src/atlas/files/FileTabs.tsx b/frontend/workspace/src/atlas/files/FileTabs.tsx deleted file mode 100644 index 70f168f5..00000000 --- a/frontend/workspace/src/atlas/files/FileTabs.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { For, Show, type JSX } from "solid-js" -import { middle } from "@/atlas/files/truncate" - -const DRAG = "text/openscience-file-tab" - -export function FileTabs(props: { - open: string[] - /** The open file the pane is showing, or undefined while browsing. */ - active?: string - onSelect: (id: string) => void - onClose: (id: string) => void - onReorder?: (id: string, to: number) => void -}): JSX.Element { - return ( - <Show when={props.open.length > 0}> - <div class="files-tabs" role="tablist" aria-label="Open files"> - <For each={props.open}> - {(name, index) => ( - // Two sibling controls, one row: selecting a tab and closing it are - // separate actions, so neither may contain the other. A close control - // nested in the tab button (a role="button" span) was invalid content - // and folded its label into the tab's accessible name — "train_lr.py - // Close train_lr.py" announced as one control. Same shape as - // SourceMenu's row. - <div class="files-tab-pair" classList={{ "files-tab-pair--active": props.active === name }} role="none"> - <button - type="button" - class="files-tab" - role="tab" - data-tab={name} - aria-selected={props.active === name} - title={name} - draggable="true" - onClick={() => props.onSelect(name)} - onDragStart={(event) => { - event.dataTransfer?.setData(DRAG, name) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" - }} - onDragOver={(event) => { - if (event.dataTransfer?.types.includes(DRAG)) event.preventDefault() - }} - onDrop={(event) => { - const dragged = event.dataTransfer?.getData(DRAG) - if (!dragged || dragged === name) return - event.preventDefault() - props.onReorder?.(dragged, index()) - }} - onKeyDown={(event) => { - if (!event.altKey || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return - event.preventDefault() - props.onReorder?.(name, index() + (event.key === "ArrowRight" ? 1 : -1)) - }} - > - <span class="files-tab__label" data-tab-label> - {middle(name, 22)} - </span> - </button> - <button - type="button" - class="files-tab__close" - data-tab-close={name} - aria-label={`Close ${name}`} - onClick={() => props.onClose(name)} - > - ✕ - </button> - </div> - )} - </For> - </div> - </Show> - ) -} diff --git a/frontend/workspace/src/atlas/files/FilesPane.css b/frontend/workspace/src/atlas/files/FilesPane.css index 6d4a0657..9ac30de9 100644 --- a/frontend/workspace/src/atlas/files/FilesPane.css +++ b/frontend/workspace/src/atlas/files/FilesPane.css @@ -1,5 +1,7 @@ .files-source { position: relative; + flex: 0 1 auto; + min-width: 0; } .files-source__button { @@ -7,21 +9,34 @@ align-items: center; gap: 8px; max-width: 100%; - padding: 7px 10px; - border: 1px solid var(--color-border-weak-base); - border-radius: 8px; - background: var(--color-bg-subtle); + min-height: 32px; + padding: 0 9px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: transparent; color: var(--color-text); font: inherit; font-size: 13px; + font-weight: var(--font-weight-medium); cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} +.files-source__glyph { + display: inline-flex; + flex: none; + color: var(--color-text-muted); } .files-source__button:hover { - background: var(--color-surface); + background: var(--color-bg-subtle); +} +.files-source__button[aria-expanded="true"] { + background: var(--color-bg-subtle); } .files-source__button:focus-visible { - outline: 1px solid var(--color-text); - outline-offset: 1px; + outline: 2px solid var(--color-border-base); + outline-offset: 0; } .files-source__name { overflow: hidden; @@ -29,8 +44,9 @@ white-space: nowrap; } .files-source__caret { + display: inline-flex; + flex: none; color: var(--color-text-faint); - font-size: 10px; } .files-menu-wrap { @@ -51,25 +67,25 @@ } .files-menu { - /* Never wider than the pane it opens inside: a docked pane is ~396px, and a - fixed-width menu plus a long path forces a horizontal scrollbar. */ + /* Container-relative, not viewport-relative: the inspector can be 320px wide + inside a desktop viewport that is ten times larger. */ position: absolute; - top: 40px; + top: 42px; left: 0; - width: min(300px, calc(100vw - 24px)); - max-height: 400px; + width: min(300px, calc(100cqi - 44px), calc(100vw - 24px)); + max-height: min(400px, calc(100vh - 96px)); overflow-y: auto; overflow-x: hidden; - padding: 8px; - border: 1px solid var(--color-border-weak-base); - border-radius: 10px; + padding: 6px; + border: 1px solid var(--color-border-base); + border-radius: var(--atlas-radius-md); background: var(--color-surface-solid); - box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55); + box-shadow: var(--atlas-shadow-float); } .files-menu__group { - padding: 9px 8px 4px; + padding: 10px 10px 5px; font-size: 11px; - letter-spacing: 0.06em; + letter-spacing: var(--letter-spacing-normal); color: var(--color-text-faint); } /* Two sibling controls, one row: picking the source and revoking it are @@ -92,15 +108,19 @@ align-items: center; gap: 10px; width: 100%; - padding: 8px; + min-height: 40px; + padding: 6px 8px; border: 0; - border-radius: 7px; + border-radius: var(--atlas-radius-xs); background: none; color: var(--color-text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } .files-menu__item:hover { background: var(--color-bg-subtle); @@ -159,11 +179,13 @@ font-size: 11px; } .files-menu__badge { - padding: 1px 5px; - border: 1px solid var(--color-border-weak-base); + min-height: 22px; + padding: 2px 6px; + border: 0; border-radius: 999px; - font-size: 9.5px; - letter-spacing: 0.04em; + background: var(--color-bg-subtle); + font-size: 11px; + letter-spacing: var(--letter-spacing-normal); color: var(--color-text-muted); } .files-menu__dot { @@ -174,18 +196,22 @@ } .files-menu__revoke { flex: 0 0 auto; - padding: 2px 6px; - border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + min-height: 32px; + padding: 0 8px; + border: 0; + border-radius: calc(var(--atlas-radius-xs) - 2px); background: none; font: inherit; font-family: var(--font-code); font-size: 10.5px; color: var(--color-text-muted); cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } .files-menu__revoke:hover { - border-color: var(--color-text-muted); + background: var(--color-bg-subtle); color: var(--color-text); } .files-menu__revoke:focus-visible { @@ -200,8 +226,29 @@ .files-table { flex: 1; + min-height: 0; + padding: 0 4px 10px; overflow-y: auto; - border-top: 1px solid var(--color-border-weak-base); +} + +.files-table__header { + position: sticky; + top: 0; + z-index: 1; + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 10px; + min-height: 30px; + padding: 0 8px; + border: 0; + background: var(--color-bg); + color: var(--color-text-faint); + font-size: 11px; +} +.files-table__header > :last-child { + min-width: 52px; + text-align: right; } .files-row { @@ -210,26 +257,34 @@ align-items: center; gap: 10px; width: 100%; - padding: 9px 12px; + min-height: 40px; + margin: 1px 0; + padding: 5px 8px; border: 0; - border-bottom: 1px solid var(--color-border-weak-base); + border-radius: var(--atlas-radius-xs); background: none; color: var(--color-text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease, + opacity 140ms ease; } .files-row:hover { background: var(--color-bg-subtle); } .files-row:focus-visible { - outline: 1px solid var(--color-text); + outline: 2px solid var(--color-border-base); outline-offset: -2px; } .files-row__glyph { + display: inline-flex; + align-items: center; + justify-content: center; color: var(--color-text-faint); - font-size: 13px; } .files-row__name { min-width: 0; @@ -252,7 +307,7 @@ color: var(--color-text-faint); } .files-empty { - padding: 30px 16px; + padding: 34px 18px; color: var(--color-text-faint); font-size: 12.5px; line-height: 1.6; @@ -265,22 +320,32 @@ background: none; } .files-trash__note { - margin: 0; - padding: 10px 12px; - border-bottom: 1px solid var(--color-border-weak-base); + margin: 6px 4px 8px; + padding: 9px 10px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); color: var(--color-text-faint); font-size: 11.5px; line-height: 1.5; } .files-restore { - padding: 4px 9px; - border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 9px; + border: 0; + border-radius: calc(var(--atlas-radius-xs) - 2px); background: none; color: var(--color-text-muted); font: inherit; font-size: 11.5px; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease, + opacity 140ms ease; } .files-restore:hover:not(:disabled) { background: var(--color-bg-subtle); @@ -330,131 +395,273 @@ background-color: color-mix(in srgb, var(--color-text) 32%, transparent); } -.files-tabs { +.files-pane { display: flex; - align-items: center; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--color-bg); + container: files-pane / inline-size; +} + +.files-browser { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + margin: 0; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; +} + +.files-browser__header { + display: flex; + flex: none; + flex-direction: column; gap: 4px; - padding: 8px 10px 0; + padding: 8px 12px 6px; + border: 0; +} + +.files-browser__toolbar { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.files-browser__toolbar .files-source { + max-width: min(48cqi, 210px); +} + +.files-browser__toolbar .files-source__button { + width: 100%; + max-width: 100%; +} + +/* The source control names a location; this compact second line explains its + storage role. It yields to breadcrumbs as soon as the person navigates into + a folder, so the header never grows into three stacked toolbars. */ +.files-source-context { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + min-height: 28px; + padding: 0 4px; + color: var(--color-text-faint); + font-size: 11.5px; + line-height: 1.35; +} +.files-source-context__label { + flex: none; + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} +.files-source-context__divider { + flex: none; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--color-border-base); +} +.files-source-context__copy { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.files-source-context__badge { + flex: none; + margin-left: auto; + padding: 2px 6px; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); + color: var(--color-text-muted); + font-size: 10.5px; + line-height: 1.5; + white-space: nowrap; +} + +.files-path { + display: flex; + width: 100%; + align-items: center; + min-width: 0; overflow-x: auto; scrollbar-width: none; } -.files-tabs::-webkit-scrollbar { +.files-path::-webkit-scrollbar { display: none; } -.files-tab { +.files-path__root, +.files-path__crumb { display: inline-flex; + flex: none; align-items: center; - gap: 7px; - flex: 0 1 auto; - min-width: 0; - max-width: 210px; - padding: 7px 10px; + justify-content: center; + min-height: 32px; + padding: 0 8px; border: 0; - border-radius: 7px; - background: none; + border-radius: var(--atlas-radius-xs); + background: transparent; color: var(--color-text-muted); font: inherit; font-size: 12.5px; cursor: pointer; - white-space: nowrap; + transition: + background-color 140ms ease, + color 140ms ease; } -.files-tab:focus-visible { - outline: 1px solid var(--color-text); +.files-path__root { + width: 32px; + padding: 0; +} +.files-path__root:hover, +.files-path__crumb:hover:not(:disabled) { + background: var(--color-bg-subtle); + color: var(--color-text); +} +.files-path__root:focus-visible, +.files-path__crumb:focus-visible { + outline: 2px solid var(--color-border-base); outline-offset: -2px; } -.files-tab__label { +.files-path__crumb:disabled { + max-width: 150px; overflow: hidden; + color: var(--color-text); + cursor: default; text-overflow: ellipsis; + white-space: nowrap; } -/* The pair carries the tab's chrome so its two controls read as one tab. */ -.files-tab-pair { +.files-path__separator { display: inline-flex; - align-items: center; - flex: 0 0 auto; - max-width: 210px; - padding-right: 5px; - border-radius: 7px; + flex: none; + color: var(--color-text-faint); } -.files-tab-pair:hover { +.files-search { + display: flex; + flex: 1 1 160px; + align-items: center; + min-height: 32px; + min-width: 0; + padding: 0 3px 0 10px; + border: 0; + border-radius: var(--atlas-radius-sm); background: var(--color-bg-subtle); -} -.files-tab-pair:hover .files-tab { color: var(--color-text); + transition: + background-color 140ms ease, + box-shadow 140ms ease; } -.files-tab-pair--active { +.files-search:focus-within { background: var(--color-surface); + box-shadow: inset 0 0 0 1px var(--color-border-base); } -.files-tab-pair--active .files-tab { +.files-search__icon { + display: inline-flex; + flex: none; + color: var(--color-text-faint); +} +.files-search__input { + flex: 1; + min-width: 0; + padding: 0 8px; + border: 0; + outline: 0; + background: transparent; color: var(--color-text); + font: inherit; + font-size: 12.5px; } -.files-tab__close { +.files-search__input::-webkit-search-cancel-button { + appearance: none; +} +.files-search__input::placeholder { + color: var(--color-text-faint); +} +.files-search__clear { display: inline-flex; - align-items: center; flex: none; - padding: 4px; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; border: 0; - border-radius: 3px; - background: none; + border-radius: var(--atlas-radius-xs); + background: transparent; color: var(--color-text-faint); - font: inherit; - font-size: 14px; - line-height: 1; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } -.files-tab__close:hover { +.files-search__clear:hover { + background: var(--color-surface); color: var(--color-text); } -.files-tab__close:focus-visible { - outline: 1px solid var(--color-text); - outline-offset: 1px; +.files-search__clear:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--color-text); } - -.files-pane { +.files-connect { display: flex; flex-direction: column; - height: 100%; - overflow: hidden; - background: var(--color-bg); -} -.files-source-row { - display: flex; - align-items: center; gap: 8px; - padding: 10px 12px 8px; + margin: 4px 12px 8px; + padding: 12px; + border: 0; + border-radius: var(--atlas-radius-md); + background: var(--color-bg-subtle); } -.files-search-row { +.files-connect__heading { display: flex; - align-items: center; - gap: 8px; - padding: 0 12px 10px; + align-items: flex-start; + gap: 12px; } -.files-search { +.files-connect__heading > span { + display: flex; flex: 1; + flex-direction: column; + gap: 2px; min-width: 0; - padding: 8px 11px; - border: 1px solid var(--color-border-weak-base); - border-radius: 8px; - background: var(--color-bg-subtle); +} +.files-connect__heading strong { color: var(--color-text); - font: inherit; - font-size: 12.5px; + font-size: 13px; + font-weight: var(--font-weight-emphasis); } -.files-search::placeholder { +.files-connect__heading small { color: var(--color-text-faint); + font-size: 11.5px; + line-height: 1.45; } -.files-search:focus { - outline: none; - border-color: var(--color-text-muted); +.files-connect__dismiss { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--color-text-faint); + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } -.files-connect { - display: flex; - flex-direction: column; - gap: 8px; - margin: 0 12px 10px; - padding: 10px; - border: 1px solid var(--color-border-weak-base); - border-radius: 8px; - background: var(--color-bg-subtle); +.files-connect__dismiss:hover { + background: var(--color-surface); + color: var(--color-text); } .files-connect__row { display: flex; @@ -475,14 +682,28 @@ } .files-connect__field select { width: 100%; - padding: 6px 8px; + min-height: 36px; + padding: 0 8px; border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + border-radius: calc(var(--atlas-radius-xs) - 2px); background: var(--color-bg); color: var(--color-text); font: inherit; font-size: 12px; } +.files-connect__path { + flex: 1; + min-width: 0; + min-height: 36px; + padding: 0 10px; + border: 1px solid var(--color-border-weak-base); + border-radius: var(--atlas-radius-xs); + background: var(--color-bg); + color: var(--color-text); + font: inherit; + font-family: var(--font-code); + font-size: 12px; +} .files-connect__note { margin: 0; color: var(--color-text-faint); @@ -490,21 +711,28 @@ line-height: 1.5; } .files-connect__note--blocked { - padding-left: 8px; - border-left: 2px solid var(--color-border-weak-base); + padding: 8px 9px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg); color: var(--color-text-muted); } .files-connect__browse, .files-connect__cancel, .files-connect__submit { - padding: 7px 11px; + min-height: 34px; + padding: 0 11px; border: 1px solid var(--color-border-weak-base); - border-radius: 7px; + border-radius: var(--atlas-radius-xs); background: none; color: var(--color-text-muted); font: inherit; font-size: 12px; cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease; } .files-connect__submit { border-color: var(--color-text-muted); @@ -527,25 +755,59 @@ } .files-notice { - margin: 0 12px 8px; + margin: 4px 12px 8px; padding: 8px 10px; - border-left: 2px solid var(--color-border-weak-base); + border: 0; + border-radius: var(--atlas-radius-sm); background: var(--color-bg-subtle); color: var(--color-text-muted); font-size: 12px; line-height: 1.5; } +.files-notice--error { + display: flex; + align-items: center; + gap: 10px; +} +.files-notice--error > span { + flex: 1; + min-width: 0; +} +.files-notice__retry { + min-width: 56px; + min-height: 32px; + padding: 6px 10px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: var(--color-bg); + color: var(--color-text); + font: inherit; + font-size: 12px; + font-weight: var(--font-weight-emphasis); + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} +.files-notice__retry:hover { + background: var(--color-surface); +} +.files-notice__retry:focus-visible { + outline: 1px solid var(--color-text); + outline-offset: 2px; +} /* ---------------------------------------------------------------- artifacts */ .artifact-surface { display: flex; + flex: 1; flex-direction: column; - gap: 10px; + gap: 12px; min-width: 0; - /* The same gutter .files-search-row uses, so the count, the group headers and - the cards line up with the search box instead of hugging the pane edge. */ - padding: 0 12px 12px; + min-height: 0; + /* Match the browser header so cards, groups, and controls share one axis. */ + padding: 4px 12px 12px; overflow-y: auto; } @@ -565,52 +827,68 @@ .artifact-toolbar__sort, .artifact-toolbar__layout button, .artifact-toolbar__prefs { - border: 1px solid var(--color-border-weak-base); - border-radius: 7px; - background: var(--color-bg-subtle); + min-height: 32px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: transparent; color: var(--color-text); font: inherit; font-size: 12.5px; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease; } .artifact-toolbar__sort { - padding: 4px 8px; + padding: 0 9px; white-space: nowrap; } .artifact-toolbar__layout { display: inline-flex; overflow: hidden; - border: 1px solid var(--color-border-weak-base); - border-radius: 7px; + padding: 2px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-subtle); } .artifact-toolbar__layout button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; border: 0; - border-radius: 0; - padding: 4px 8px; + border-radius: calc(var(--atlas-radius-xs) - 2px); + padding: 0; background: transparent; color: var(--color-text-faint); line-height: 1.35; } .artifact-toolbar__layout button[aria-pressed="true"] { - background: var(--color-surface); + background: var(--color-surface-solid); + box-shadow: var(--atlas-shadow-xs); color: var(--color-text); } .artifact-toolbar__prefs { - padding: 4px 7px; - border-color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + padding: 0; background: transparent; color: var(--color-text-faint); line-height: 1.3; } .artifact-toolbar__sort:hover, +.artifact-toolbar__layout button:hover, .artifact-toolbar__prefs:hover { - background: var(--color-surface); + background: var(--color-bg-subtle); } .artifact-toolbar__sort:focus-visible, .artifact-toolbar__layout button:focus-visible, .artifact-toolbar__prefs:focus-visible { - outline: 1px solid var(--color-text); - outline-offset: 1px; + outline: 2px solid var(--color-border-base); + outline-offset: 0; } .artifact-group { @@ -622,7 +900,7 @@ .artifact-group__name { overflow: hidden; font-size: 12.5px; - font-weight: 550; + font-weight: var(--font-weight-emphasis); text-overflow: ellipsis; white-space: nowrap; } @@ -637,7 +915,7 @@ /* auto-fill, so a wider pane gains columns instead of stretching cards. */ .artifact-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(136px, 100%), 1fr)); gap: 10px; } .artifact-list { @@ -653,12 +931,16 @@ .artifact-card[data-layout="grid"] { flex-direction: column; overflow: hidden; - border: 1px solid var(--color-border-weak-base); - border-radius: 10px; + border: 0; + border-radius: var(--atlas-radius-md); background: var(--color-bg-subtle); + transition: + background-color 150ms ease, + box-shadow 150ms ease; } .artifact-card[data-layout="grid"]:hover { - border-color: var(--color-border-base); + background: var(--color-surface); + box-shadow: var(--atlas-shadow-xs); } .artifact-card__open { display: flex; @@ -678,8 +960,10 @@ .artifact-card[data-layout="list"] { align-items: center; gap: 8px; - padding: 4px 6px; - border-radius: 8px; + min-height: 42px; + padding: 5px 4px 5px 6px; + border-radius: var(--atlas-radius-xs); + transition: background-color 140ms ease; } .artifact-card[data-layout="list"]:hover { background: var(--color-bg-subtle); @@ -689,8 +973,8 @@ gap: 10px; } .artifact-card__open:focus-visible { - outline: 1px solid var(--color-text); - outline-offset: -1px; + outline: 2px solid var(--color-border-base); + outline-offset: -2px; } .artifact-card__label { @@ -714,6 +998,16 @@ text-overflow: ellipsis; white-space: nowrap; } +.artifact-card[data-layout="grid"] .artifact-card__name { + display: -webkit-box; + min-height: 4.05em; + line-height: 1.35; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} .artifact-card[data-layout="list"] .artifact-card__name { flex: 1; } @@ -730,26 +1024,27 @@ background: var(--color-bg-subtle); } .artifact-card[data-layout="grid"] .artifact-thumb { - height: 94px; - border-bottom: 1px solid var(--color-border-weak-base); + height: clamp(74px, 24cqi, 94px); + border: 0; } .artifact-card[data-layout="list"] .artifact-thumb { flex: none; width: 30px; height: 30px; - border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + border: 0; + border-radius: calc(var(--atlas-radius-xs) - 2px); } .artifact-thumb--image { width: 100%; height: 100%; - background: #fff; + background: var(--color-surface-solid); object-fit: contain; } .artifact-card[data-layout="list"] .artifact-thumb--image { object-fit: cover; } -/* Ten lines of real source, faded out rather than clipped mid-glyph. */ +/* A preview is a bounded sample, not a floating fade. Keeping the edge solid + prevents a gradient from being visibly clipped by the card boundary. */ .artifact-thumb--text { width: 100%; margin: 0; @@ -760,8 +1055,6 @@ font-size: 7.4px; line-height: 1.55; white-space: pre; - mask-image: linear-gradient(180deg, #000 56%, transparent 100%); - -webkit-mask-image: linear-gradient(180deg, #000 56%, transparent 100%); } .artifact-card[data-layout="list"] .artifact-thumb--text { padding: 3px; @@ -775,13 +1068,13 @@ } .artifact-thumb--binary span { padding: 4px 9px; - border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + border: 0; + border-radius: calc(var(--atlas-radius-xs) - 2px); + background: var(--color-surface-solid); color: var(--color-text-faint); font-family: var(--font-code); font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: var(--letter-spacing-normal); } .artifact-card[data-layout="list"] .artifact-thumb--binary span { padding: 0; @@ -796,23 +1089,28 @@ position: absolute; top: 6px; right: 6px; - padding: 1px 5px; - border: 1px solid var(--color-border-weak-base); - border-radius: 6px; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: calc(var(--atlas-radius-xs) - 2px); opacity: 0; background: var(--color-surface-solid); color: var(--color-text); font: inherit; - font-size: 13px; - line-height: 1.35; cursor: pointer; - transition: opacity 90ms ease; + box-shadow: var(--atlas-shadow-xs); + transition: + opacity 140ms ease, + background-color 140ms ease; } .artifact-card[data-layout="list"] .artifact-card__actions { position: static; flex: none; - border-color: transparent; background: transparent; + box-shadow: none; } .artifact-card:hover .artifact-card__actions, .artifact-card__actions:focus-visible, @@ -847,9 +1145,9 @@ min-width: 168px; padding: 4px; border: 1px solid var(--color-border-base); - border-radius: 9px; + border-radius: var(--atlas-radius-sm); background: var(--color-surface-solid); - box-shadow: 0 12px 30px -12px rgb(0 0 0 / 0.55); + box-shadow: 0 12px 30px -12px rgb(8 10 14 / 0.38); } /* The toolbar spans the pane, so anchoring this one to its right edge keeps it inside without measurement. */ @@ -864,9 +1162,10 @@ display: flex; align-items: center; gap: 8px; + min-height: 36px; padding: 6px 9px; border: 0; - border-radius: 6px; + border-radius: calc(var(--atlas-radius-xs) - 2px); background: transparent; color: var(--color-text); font: inherit; @@ -874,6 +1173,9 @@ text-align: left; text-decoration: none; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } .artifact-menu button:hover, .artifact-menu a:hover { @@ -902,14 +1204,19 @@ display: flex; flex: 1; flex-direction: column; + margin: 0; min-height: 0; + overflow: hidden; + border: 0; + border-radius: 0; + background: transparent; } .remote-view__bar { display: flex; align-items: center; gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid var(--color-border-weak-base); + padding: 10px 12px; + border: 0; } .remote-view__title { display: flex; @@ -934,25 +1241,30 @@ flex: none; align-items: center; gap: 5px; - padding: 4px 8px; - border: 1px solid var(--color-border-weak-base); - border-radius: 7px; - background: var(--color-bg-subtle); + min-height: 32px; + padding: 0 9px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: transparent; color: var(--color-text); font: inherit; font-size: 12.5px; cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; } .remote-view__action:hover { - background: var(--color-surface); + background: var(--color-bg-subtle); } .remote-view__action:focus-visible { outline: 1px solid var(--color-text); outline-offset: 1px; } .remote-view__action--icon { - padding: 4px 6px; - border-color: transparent; + justify-content: center; + width: 32px; + padding: 0; background: transparent; color: var(--color-text-faint); } @@ -978,7 +1290,7 @@ display: block; max-width: 100%; margin: 12px auto; - background: #fff; + background: var(--color-surface-solid); } .remote-view__frame { width: 100%; @@ -1007,7 +1319,7 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 12px 8px; + padding: 4px 12px 8px; color: var(--color-text-muted); font-size: 12.5px; } @@ -1032,6 +1344,31 @@ animation: none; opacity: 0.6; } + + .files-source__button, + .files-menu__item, + .files-menu__revoke, + .files-path__root, + .files-path__crumb, + .files-search, + .files-search__clear, + .files-connect__dismiss, + .files-connect__browse, + .files-connect__cancel, + .files-connect__submit, + .files-notice__retry, + .files-row, + .files-restore, + .artifact-toolbar__sort, + .artifact-toolbar__layout button, + .artifact-toolbar__prefs, + .artifact-card, + .artifact-card__actions, + .artifact-menu button, + .artifact-menu a, + .remote-view__action { + transition: none; + } } /* Shown but not actionable: the rows describe the folder being left. */ @@ -1039,3 +1376,79 @@ opacity: 0.5; cursor: default; } + +@container files-pane (max-width: 340px) { + .files-browser__header, + .artifact-surface { + padding: 10px; + } + + .files-connect__row { + align-items: stretch; + flex-direction: column; + } + + .files-connect__browse, + .files-connect__cancel, + .files-connect__submit { + width: 100%; + } + + .files-connect__row--end { + flex-direction: row; + } + + .artifact-toolbar { + flex-wrap: wrap; + } +} + +/* Source details remain available in the picker at narrow widths. Removing + the helper row here preserves the primary source/search controls and avoids + spending scarce vertical space on text that would be truncated anyway. */ +@container files-pane (max-width: 400px) { + .files-source-context { + display: none; + } +} + +@media (pointer: coarse) { + .files-source__button, + .files-menu__item, + .files-menu__revoke, + .files-row, + .files-path__root, + .files-path__crumb, + .files-search, + .files-search__clear, + .files-connect__dismiss, + .files-connect__browse, + .files-connect__cancel, + .files-connect__submit, + .files-notice__retry, + .files-restore, + .artifact-toolbar__sort, + .artifact-toolbar__layout button, + .artifact-toolbar__prefs, + .artifact-card__actions, + .artifact-menu button, + .artifact-menu a, + .remote-view__action { + min-height: 44px; + } + + .files-search__clear, + .files-connect__dismiss, + .files-path__root, + .artifact-toolbar__prefs, + .artifact-card__actions, + .remote-view__action--icon { + width: 44px; + height: 44px; + } + + .artifact-toolbar__layout button, + .artifact-toolbar__prefs { + min-width: 44px; + } +} diff --git a/frontend/workspace/src/atlas/files/RemoteFileView.tsx b/frontend/workspace/src/atlas/files/RemoteFileView.tsx index ec706344..38db3351 100644 --- a/frontend/workspace/src/atlas/files/RemoteFileView.tsx +++ b/frontend/workspace/src/atlas/files/RemoteFileView.tsx @@ -37,10 +37,10 @@ interface Cached { /** * Files already fetched out of a Volume. * - * Switching tabs unmounts this viewer, so without it every switch went back to - * Modal -- seconds each time for bytes already in hand. Keyed by volume, path - * and size: a Volume file can change, unlike an artifact version, and the size - * is the cheapest signal of that a listing gives us. A file edited in place to + * Closing a focused preview unmounts this viewer, so without it reopening went + * back to Modal -- seconds each time for bytes already in hand. Keyed by volume, + * path and size: a Volume file can change, unlike an artifact version, and the + * size is the cheapest signal a listing gives us. A file edited in place to * exactly the same length serves the previous bytes until the pane reloads. */ const fetched = new Map<string, Cached>() diff --git a/frontend/workspace/src/atlas/files/SourceMenu.test.ts b/frontend/workspace/src/atlas/files/SourceMenu.test.ts index 0f602967..ee55d05d 100644 --- a/frontend/workspace/src/atlas/files/SourceMenu.test.ts +++ b/frontend/workspace/src/atlas/files/SourceMenu.test.ts @@ -35,10 +35,17 @@ const mount = (view: () => JSX.Element) => { } const SOURCES = [ - { id: "artifacts", group: "Artifacts" as const, name: "All artifacts", root: "", kind: "artifacts" as const }, + { + id: "artifacts", + group: "Saved" as const, + name: "Saved artifacts", + detail: "Durable, versioned deliverables", + root: "", + kind: "artifacts" as const, + }, { id: "project", - group: "This computer" as const, + group: "Working files" as const, name: "openscience-demoo", sub: "/home/keertan/codes/openscience-demoo", root: "/p", @@ -46,7 +53,7 @@ const SOURCES = [ }, { id: "ro", - group: "This computer" as const, + group: "Working files" as const, name: "pdebench", sub: "/home/keertan/data/pdebench", root: "/d", @@ -61,27 +68,30 @@ describe("source menu", () => { const button = host.querySelector<HTMLButtonElement>("[data-source-button]") expect(button?.textContent).toContain("openscience-demoo") + expect(button?.querySelector(".files-source__glyph svg")).not.toBeNull() expect(host.querySelector("[data-source-menu]")).toBeNull() button?.click() expect(host.querySelector("[data-source-menu]")).not.toBeNull() expect([...host.querySelectorAll("[data-source-group]")].map((n) => n.textContent)).toEqual([ - "Artifacts", - "This computer", + "Saved", + "Working files", ]) }) - test("reports the chosen source and closes", () => { + test("reports the chosen source, closes, and restores focus", async () => { const picked: string[] = [] const host = mount(() => subject.SourceMenu({ sources: SOURCES, active: SOURCES[1]!, onPick: (s) => picked.push(s.id) }), ) host.querySelector<HTMLButtonElement>("[data-source-button]")?.click() host.querySelector<HTMLButtonElement>('[data-source-item="ro"]')?.click() + await Promise.resolve() expect(picked).toEqual(["ro"]) expect(host.querySelector("[data-source-menu]")).toBeNull() + expect(document.activeElement).toBe(host.querySelector("[data-source-button]")) }) test("marks the active source and badges a read-only grant", () => { @@ -90,10 +100,22 @@ describe("source menu", () => { expect(host.querySelector('[data-source-item="project"]')?.getAttribute("aria-checked")).toBe("true") expect(host.querySelector('[data-source-item="ro"]')?.getAttribute("aria-checked")).toBe("false") - expect(host.querySelector('[data-source-item="ro"]')?.textContent).toContain("ro") + expect(host.querySelector('[data-source-item="ro"]')?.textContent).toContain("Read only") + }) + + test("explains saved artifacts without pretending they are a filesystem path", () => { + const host = mount(() => subject.SourceMenu({ sources: SOURCES, active: SOURCES[0]!, onPick: () => {} })) + host.querySelector<HTMLButtonElement>("[data-source-button]")?.click() + + expect(host.querySelector('[data-source-item="artifacts"] .files-menu__context')?.textContent).toBe( + "Durable, versioned deliverables", + ) + expect(host.querySelector('[data-source-item="project"] .files-menu__sub')?.textContent).toContain( + "/home/keertan/codes", + ) }) - test("offers revoke on a connected grant only, and revoking does not also pick it", () => { + test("offers revoke on a connected grant only, and revoking does not also pick it", async () => { const picked: string[] = [] const revoked: string[] = [] const host = mount(() => @@ -110,10 +132,12 @@ describe("source menu", () => { expect(host.querySelector('[data-source-revoke="artifacts"]')).toBeNull() host.querySelector<HTMLElement>('[data-source-revoke="ro"]')?.click() + await Promise.resolve() expect(revoked).toEqual(["ro"]) expect(picked).toEqual([]) expect(host.querySelector("[data-source-menu]")).toBeNull() + expect(document.activeElement).toBe(host.querySelector("[data-source-button]")) }) test("keeps revoke a sibling of the source it revokes, not a control inside it", () => { @@ -131,12 +155,12 @@ describe("source menu", () => { expect(item.contains(revoke)).toBe(false) expect(item.querySelector("button, [role='button']")).toBeNull() expect(item.textContent).not.toContain("Revoke") - // Each control is a real button, so each is keyboard reachable and carries - // its own accessible name with no tabindex or key handler of its own. + // Each control is a real button with a distinct accessible name. The menu + // owns one roving tab stop and Arrow-key traversal. expect(item.tagName).toBe("BUTTON") expect(revoke.tagName).toBe("BUTTON") expect(revoke.getAttribute("aria-label")).toBe("Revoke access to pdebench") - expect(revoke.getAttribute("tabindex")).toBeNull() + expect(revoke.getAttribute("tabindex")).toBe("-1") }) test("hides the revoke control when no handler can act on it", () => { @@ -150,11 +174,52 @@ describe("source menu", () => { const css = readFileSync(fileURLToPath(new URL("./FilesPane.css", import.meta.url)), "utf8") expect(css).toMatch(/\.files-menu\s*\{[^}]*overflow-x: hidden/s) - expect(css).toMatch(/\.files-menu\s*\{[^}]*width: min\(/s) + expect(css).toMatch(/\.files-menu\s*\{[^}]*width: min\([^}]*100cqi/s) + expect(css).toMatch(/\.files-browser\s*\{[^}]*overflow: visible/s) // A 1fr grid track will not shrink below its content without this. expect(css).toMatch(/\.files-menu__item\s*>\s*span:nth-child\(2\)\s*\{[^}]*min-width: 0/s) }) + test("moves focus into the menu and Escape returns it to the trigger", async () => { + const host = mount(() => subject.SourceMenu({ sources: SOURCES, active: SOURCES[1]!, onPick: () => {} })) + const trigger = host.querySelector<HTMLButtonElement>("[data-source-button]")! + + trigger.click() + await Promise.resolve() + + expect(document.activeElement).toBe(host.querySelector('[role="menuitemradio"]')) + document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) + await Promise.resolve() + + expect(host.querySelector("[data-source-menu]")).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + test("uses roving arrow navigation and keeps the invisible scrim out of the tab order", async () => { + const host = mount(() => subject.SourceMenu({ sources: SOURCES, active: SOURCES[1]!, onPick: () => {} })) + const trigger = host.querySelector<HTMLButtonElement>("[data-source-button]")! + + trigger.click() + await Promise.resolve() + const options = [...host.querySelectorAll<HTMLElement>('[role="menuitemradio"]')] + const last = options.at(-1)! + expect(document.activeElement).toBe(options[0]) + expect(host.querySelector(".files-menu__scrim")?.getAttribute("tabindex")).toBe("-1") + expect(host.querySelector(".files-menu__scrim")?.getAttribute("aria-hidden")).toBe("true") + + options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })) + expect(document.activeElement).toBe(options[1]) + options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })) + expect(document.activeElement).toBe(last) + last.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })) + expect(document.activeElement).toBe(options[0]) + + options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })) + await Promise.resolve() + expect(host.querySelector("[data-source-menu]")).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + // The kinds were text glyphs (a square for anything with a root), so a // connected folder, the project and a cloud provider all drew identically. test("renders an icon per source kind rather than one square for all of them", () => { @@ -172,5 +237,6 @@ describe("source menu", () => { expect(glyphs.length).toBe(host.querySelectorAll("[data-source-item]").length + 1) expect(host.querySelector(".files-menu__glyph")?.textContent?.trim()).toBe("") + expect(host.querySelector(".files-source__caret svg")).not.toBeNull() }) }) diff --git a/frontend/workspace/src/atlas/files/SourceMenu.tsx b/frontend/workspace/src/atlas/files/SourceMenu.tsx index d60d51e6..be5440aa 100644 --- a/frontend/workspace/src/atlas/files/SourceMenu.tsx +++ b/frontend/workspace/src/atlas/files/SourceMenu.tsx @@ -1,6 +1,15 @@ import { For, Show, createSignal, type JSX } from "solid-js" import { groupSources, type PaneSource } from "@/atlas/files/sources" -import { IconArchive, IconCloud, IconFolder, IconFolderAdd, IconLink, IconTrash } from "@/atlas/shared/Icon" +import { + IconArchive, + IconChevronDown, + IconCloud, + IconFolder, + IconFolderAdd, + IconLink, + IconTrash, +} from "@/atlas/shared/Icon" +import "./file-items.css" /** * One icon per kind of place files come from. A connected folder is drawn as a @@ -30,43 +39,104 @@ export function SourceMenu(props: { onOpen?: () => void }): JSX.Element { const [open, setOpen] = createSignal(false) - const pick = (source: PaneSource) => { + const refs: { trigger?: HTMLButtonElement; menu?: HTMLDivElement } = {} + const items = () => Array.from(refs.menu?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? []) + const focusItem = (item: HTMLElement | undefined) => { + if (!item) return + items().forEach((candidate) => (candidate.tabIndex = candidate === item ? 0 : -1)) + item.focus() + } + const close = (restoreFocus = false) => { setOpen(false) + if (restoreFocus) + queueMicrotask(() => { + const active = document.activeElement + if (active?.isConnected && active !== document.body) return + refs.trigger?.focus() + }) + } + const toggle = () => { + if (open()) { + close() + return + } + props.onOpen?.() + setOpen(true) + queueMicrotask(() => focusItem(items()[0])) + } + const pick = (source: PaneSource) => { + close(true) props.onPick(source) } const revoke = (source: PaneSource) => { - setOpen(false) + close(true) props.onRevoke?.(source) } return ( <div class="files-source"> <button + ref={(element) => { + refs.trigger = element + }} type="button" class="files-source__button" data-source-button + data-source-kind={props.active.kind} aria-haspopup="menu" aria-expanded={open()} - onClick={() => { - if (!open()) props.onOpen?.() - setOpen(!open()) - }} + aria-label={`File source: ${props.active.name}`} + title={props.active.detail ?? props.active.sub} + onClick={toggle} > + <span class="files-source__glyph" aria-hidden="true"> + {glyph(props.active.kind)({ size: 15, strokeWidth: 1.5 })} + </span> <span class="files-source__name">{props.active.name}</span> <span class="files-source__caret" aria-hidden="true"> - ▾ + <IconChevronDown size={12} strokeWidth={1.5} /> </span> </button> <Show when={open()}> <div class="files-menu-wrap"> - <button - type="button" - class="files-menu__scrim" - aria-label="Close source menu" - onClick={() => setOpen(false)} - /> - <div class="files-menu" data-source-menu role="menu"> + <button type="button" class="files-menu__scrim" tabindex="-1" aria-hidden="true" onClick={() => close()} /> + <div + ref={(element) => { + refs.menu = element + }} + class="files-menu" + data-source-menu + role="menu" + onKeyDown={(event) => { + if (event.key === "Escape" || event.key === "Tab") { + event.preventDefault() + event.stopPropagation() + close(true) + return + } + const options = items() + const current = options.indexOf(document.activeElement as HTMLElement) + const target = + event.key === "Home" + ? options[0] + : event.key === "End" + ? options.at(-1) + : event.key === "ArrowDown" + ? options[(current + 1 + options.length) % options.length] + : event.key === "ArrowUp" + ? options[(current - 1 + options.length) % options.length] + : undefined + if (!target) return + event.preventDefault() + focusItem(target) + }} + onFocusOut={(event) => { + const next = event.relatedTarget + if (next instanceof Node && (refs.menu?.contains(next) || refs.trigger?.contains(next))) return + close() + }} + > <For each={groupSources(props.sources)}> {(group) => ( <> @@ -87,7 +157,9 @@ export function SourceMenu(props: { type="button" class="files-menu__item" role="menuitemradio" + tabindex="-1" data-source-item={source.id} + data-source-kind={source.kind} aria-checked={source === props.active} onClick={() => pick(source)} > @@ -99,10 +171,21 @@ export function SourceMenu(props: { <Show when={source.sub}> <span class="files-menu__sub">{source.sub}</span> </Show> + <Show when={!source.sub && source.detail}> + <span class="files-menu__context">{source.detail}</span> + </Show> </span> <span class="files-menu__tail"> <Show when={source.readonly}> - <span class="files-menu__badge">ro</span> + <span class="files-menu__badge">Read only</span> + </Show> + <Show when={source.kind === "connected" && !source.readonly}> + <span + class="files-menu__badge" + title="Approved file tools may write here; runtimes do not receive a writable mount." + > + Tool write + </span> </Show> <Show when={source.live}> <span class="files-menu__dot" aria-label="Reachable" /> @@ -119,6 +202,7 @@ export function SourceMenu(props: { type="button" class="files-menu__revoke" role="menuitem" + tabindex="-1" data-source-revoke={source.id} aria-label={`Revoke access to ${source.name}`} onClick={() => revoke(source)} @@ -138,8 +222,10 @@ export function SourceMenu(props: { type="button" class="files-menu__item" data-source-add + role="menuitem" + tabindex="-1" onClick={() => { - setOpen(false) + close(true) props.onAdd?.() }} > diff --git a/frontend/workspace/src/atlas/files/TrashList.tsx b/frontend/workspace/src/atlas/files/TrashList.tsx index 1750ff92..45edbf58 100644 --- a/frontend/workspace/src/atlas/files/TrashList.tsx +++ b/frontend/workspace/src/atlas/files/TrashList.tsx @@ -1,5 +1,7 @@ import { For, Show, type JSX } from "solid-js" import type { StoredArtifact } from "@/artifacts/store" +import { IconFile, IconRefresh } from "@/atlas/shared/Icon" +import { ago } from "./ago" /** * The recovery half of the artifact store. StoredArtifactView promises that a @@ -9,25 +11,39 @@ import type { StoredArtifact } from "@/artifacts/store" export function TrashList(props: { rows: StoredArtifact[] busy?: boolean + filtered?: boolean + loading?: boolean + unavailable?: boolean onRestore: (artifact: StoredArtifact) => void }): JSX.Element { return ( <div class="files-table" data-trash-list> - <p class="files-trash__note">Deleted artifacts keep every version and stay recoverable for 30 days.</p> + <p class="files-trash__note">Deleted artifacts remain recoverable for 30 days, including saved versions.</p> - <Show when={props.rows.length} fallback={<div class="files-empty">Trash is empty.</div>}> + <Show + when={props.rows.length} + fallback={ + <Show when={!props.loading && !props.unavailable}> + <div class="files-empty"> + {props.filtered ? "No deleted artifacts match this search." : "Trash is empty."} + </div> + </Show> + } + > <For each={props.rows}> {(artifact) => ( - <div - class="files-row files-row--trash" - data-trash-row={artifact.id} - title={`${artifact.kind} · ${artifact.versionCount} version${artifact.versionCount === 1 ? "" : "s"}`} - > + <div class="files-row files-row--trash" data-trash-row={artifact.id}> <span class="files-row__glyph" aria-hidden="true"> - ◌ + <IconFile size={15} strokeWidth={1.45} /> </span> - <span class="files-row__name" data-trash-name> - {artifact.title} + <span class="files-row__name files-row__identity"> + <span data-trash-name>{artifact.title}</span> + <span class="files-row__meta" data-trash-meta> + {artifact.versionCount} {artifact.versionCount === 1 ? "version" : "versions"} + <Show when={artifact.trashedAt ?? artifact.updatedAt}> + {(deleted) => <> · Deleted {ago(deleted())}</>} + </Show> + </span> </span> <button type="button" @@ -37,6 +53,7 @@ export function TrashList(props: { disabled={props.busy} onClick={() => props.onRestore(artifact)} > + <IconRefresh size={13} strokeWidth={1.5} /> Restore </button> </div> diff --git a/frontend/workspace/src/atlas/files/artifact-boundaries.test.ts b/frontend/workspace/src/atlas/files/artifact-boundaries.test.ts index eb3893ea..9cdefbc5 100644 --- a/frontend/workspace/src/atlas/files/artifact-boundaries.test.ts +++ b/frontend/workspace/src/atlas/files/artifact-boundaries.test.ts @@ -162,15 +162,15 @@ describe("artifact grid inside the app's boundaries", () => { await settle(120) const afterMount = reads + host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() host.querySelector<HTMLButtonElement>("[data-artifact-layout='list']")!.click() await settle(120) - host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() host.querySelector<HTMLButtonElement>("[data-pref='sizes']")!.click() await settle(120) // Sorting regroups, which makes <For> recreate every card -- the previews // are cached by version id so that costs no reads either. - host.querySelector<HTMLButtonElement>("[data-artifact-sort]")!.click() + host.querySelector<HTMLButtonElement>("[data-artifact-sort='name']")!.click() await settle(120) expect(afterMount).toBe(2) @@ -203,9 +203,9 @@ describe("artifact grid inside the app's boundaries", () => { const before = [...host.querySelectorAll("[data-card-open]")] expect(before).toHaveLength(2) + host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() host.querySelector<HTMLButtonElement>("[data-artifact-layout='list']")!.click() await settle(80) - host.querySelector<HTMLButtonElement>("[data-artifact-prefs]")!.click() host.querySelector<HTMLButtonElement>("[data-pref='sizes']")!.click() await settle(80) @@ -242,7 +242,7 @@ describe("artifact grid inside the app's boundaries", () => { await settle() expect(host.textContent).not.toContain("BOUNDARY-CAUGHT") - expect(host.textContent).toContain("No artifacts saved yet.") + expect(host.textContent).toContain("No saved artifacts yet") } finally { if (original) Object.defineProperty(globalThis, "localStorage", original) } diff --git a/frontend/workspace/src/atlas/files/artifact-styles.test.ts b/frontend/workspace/src/atlas/files/artifact-styles.test.ts index 998f548a..5b8dd7ba 100644 --- a/frontend/workspace/src/atlas/files/artifact-styles.test.ts +++ b/frontend/workspace/src/atlas/files/artifact-styles.test.ts @@ -26,6 +26,14 @@ describe("artifact grid styles", () => { "--color-text-weak", "--syntax-critical", "--font-code", + "--font-weight-emphasis", + "--font-weight-medium", + "--letter-spacing-normal", + "--atlas-radius-xs", + "--atlas-radius-sm", + "--atlas-radius-md", + "--atlas-shadow-xs", + "--atlas-shadow-float", ]) const unknown = [...new Set(used)].filter((name) => !defined.has(name)) @@ -47,13 +55,63 @@ describe("artifact grid styles", () => { expect(css()).toContain(".artifact-menu button.artifact-menu__danger") }) - test("gives the grid the same gutter as the rest of the pane", () => { - const gutter = /\.files-search-row\s*\{[^}]*padding: 0 12px/s.test(css()) - expect(gutter).toBe(true) - expect(css()).toMatch(/\.artifact-surface\s*\{[^}]*padding: 0 12px/s) + test("keeps the browser continuous while sharing the content gutter", () => { + const styles = css() + + expect(styles).toMatch(/\.files-browser\s*\{[^}]*margin: 0;[^}]*border: 0;[^}]*border-radius: 0/s) + expect(styles).toMatch(/\.files-browser__header\s*\{[^}]*padding: 8px 12px 6px;[^}]*border: 0/s) + expect(styles).toMatch(/\.artifact-surface\s*\{[^}]*padding: 4px 12px 12px/s) + expect(styles).toMatch(/\.remote-view\s*\{[^}]*margin: 0;[^}]*border: 0;[^}]*border-radius: 0/s) + }) + + test("keeps source and search in one compact resize-safe toolbar", () => { + const styles = css() + + expect(styles).toMatch(/\.files-browser__toolbar\s*\{[^}]*display: flex;[^}]*min-width: 0/s) + expect(styles).toMatch(/\.files-browser__toolbar \.files-source\s*\{[^}]*max-width: min\(48cqi, 210px\)/s) + expect(styles).toMatch(/\.files-search\s*\{[^}]*flex: 1 1 160px;[^}]*min-height: 32px/s) + expect(styles).toMatch( + /\.artifact-grid\s*\{[^}]*grid-template-columns: repeat\(auto-fill, minmax\(min\(136px, 100%\), 1fr\)\)/s, + ) + expect(styles).toMatch( + /\.artifact-card\[data-layout="grid"\] \.artifact-card__name\s*\{[^}]*overflow-wrap: anywhere;[^}]*-webkit-line-clamp: 3/s, + ) + }) + + test("uses spacing and hover states instead of nested file borders", () => { + const styles = css() + + expect(styles).toMatch(/\.files-source__button\s*\{[^}]*border: 0;/s) + expect(styles).toMatch(/\.files-search\s*\{[^}]*border: 0;/s) + expect(styles).toMatch(/\.files-row\s*\{[^}]*border: 0;/s) + expect(styles).toMatch(/\.artifact-card\[data-layout="grid"\]\s*\{[^}]*border: 0;/s) + expect(styles).toMatch(/\.artifact-card\[data-layout="grid"\] \.artifact-thumb\s*\{[^}]*border: 0;/s) + }) + + test("uses the shared radius ladder and no clipped decorative gradients", () => { + const styles = css() + const rawPixelRadii = [...styles.matchAll(/border-radius:\s*(\d+(?:\.\d+)?)px/g)].map((match) => match[1]!) + + expect(styles).toContain("border-radius: var(--atlas-radius-md)") + expect(styles).toContain("border-radius: var(--atlas-radius-sm)") + expect(styles).toContain("border-radius: var(--atlas-radius-xs)") + expect(styles).toMatch(/\.files-menu__badge\s*\{[^}]*border-radius: 999px/s) + expect(styles).toMatch(/::-webkit-scrollbar-thumb\s*\{[^}]*border-radius: 999px/s) + expect(rawPixelRadii).toEqual(["999", "999"]) + expect(styles).not.toContain("linear-gradient") + expect(styles).not.toContain("mask-image") }) test("reveals the card's actions on focus, not only on hover", () => { expect(css()).toContain(".artifact-card__actions:focus-visible") }) + + test("uses calm desktop motion and full coarse-pointer targets", () => { + const styles = css() + + expect(styles).toContain("background-color 140ms ease") + expect(styles.match(/@media \(pointer: coarse\)/g)).toHaveLength(1) + expect(styles).toMatch(/@media \(pointer: coarse\)[\s\S]*min-height: 44px/) + expect(styles).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*transition: none/) + }) }) diff --git a/frontend/workspace/src/atlas/files/file-items.css b/frontend/workspace/src/atlas/files/file-items.css new file mode 100644 index 00000000..f56d1003 --- /dev/null +++ b/frontend/workspace/src/atlas/files/file-items.css @@ -0,0 +1,236 @@ +/* + * Catalog-specific refinements for SourceMenu, ArtifactGrid/Card, FileTable, + * and TrashList. The pane shell stays in FilesPane.css; this file keeps the + * item hierarchy independently testable and avoids coupling card semantics to + * the resizable right-pane layout. + */ + +.files-menu__context { + display: block; + margin-top: 1px; + overflow: hidden; + color: var(--color-text-faint); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.files-menu__item[data-source-kind="artifacts"] .files-menu__label, +.files-menu__item[data-source-kind="project"] .files-menu__label { + font-weight: var(--font-weight-medium); +} + +.files-menu__item[data-source-kind="trash"] { + color: var(--color-text-muted); +} + +.files-menu__item[data-source-kind="trash"] .files-menu__glyph { + color: var(--color-text-faint); +} + +.artifact-toolbar__primary { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.artifact-toolbar__primary .artifact-toolbar__count { + margin-right: 0; + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); +} + +.artifact-toolbar__hint { + overflow: hidden; + color: var(--color-text-faint); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifact-toolbar__controls { + position: relative; + display: inline-flex; + flex: none; +} + +.artifact-toolbar__controls .artifact-toolbar__prefs { + width: auto; + gap: 5px; + padding: 0 8px; + color: var(--color-text-muted); +} + +.artifact-toolbar__controls .artifact-menu--prefs { + top: 36px; + min-width: 180px; +} + +.artifact-menu__section { + padding: 7px 9px 3px; + color: var(--color-text-faint); + font-size: 10.5px; + line-height: 1.35; +} + +.artifact-menu__separator { + height: 1px; + margin: 4px 8px; + background: var(--color-border-weak-base); +} + +.artifact-card__sub { + display: flex; + align-items: center; + min-width: 0; +} + +.artifact-card__sub > span { + flex: none; +} + +.artifact-card__meta-separator { + margin: 0 4px; + color: var(--color-text-faint); +} + +.artifact-card__version { + color: var(--color-text-muted); +} + +.artifact-card[data-layout="list"] .artifact-card__sub { + max-width: min(52%, 230px); + overflow: hidden; + text-overflow: ellipsis; +} + +.artifact-card[data-layout="list"] .artifact-card__sub > span { + overflow: hidden; + text-overflow: ellipsis; +} + +.files-empty--artifacts, +.files-empty--folder { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 280px; + margin: 24px auto 0; + padding: 16px; + text-align: center; + text-wrap: pretty; +} + +.files-empty--artifacts strong, +.files-empty--folder strong { + color: var(--color-text-muted); + font-size: 12.5px; + font-weight: var(--font-weight-medium); +} + +.files-empty--artifacts span, +.files-empty--folder span { + color: var(--color-text-faint); + font-size: 11.5px; + line-height: 1.5; +} + +.files-row__identity { + display: flex; + flex-direction: column; + gap: 1px; +} + +.files-row__identity > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.files-row__meta { + color: var(--color-text-faint); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +/* Preserve the selected layout at every width. A compact grid remains a grid; + * silently drawing it as a list would make aria-checked and the visible result + * disagree. Two equal columns keep the choice useful at the narrowest pane. */ +@container files-pane (max-width: 400px) { + .artifact-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .artifact-grid .artifact-card[data-layout="grid"] { + min-height: 0; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-card__open { + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-thumb { + width: 100%; + height: 68px; + border-radius: 0; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-thumb--text { + padding: 6px; + font-size: 6.5px; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-thumb--binary span { + padding: 3px 7px; + font-size: 9px; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-card__label { + min-width: 0; + padding: 7px 8px 8px; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-card__name { + display: -webkit-box; + min-height: 2.7em; + overflow: hidden; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .artifact-grid .artifact-card[data-layout="grid"] .artifact-card__actions { + top: 6px; + right: 2px; + opacity: 1; + background: var(--color-surface-solid); + box-shadow: var(--atlas-shadow-xs); + } + + .artifact-card__sub { + overflow: hidden; + } + + .artifact-card__sub [data-card-saved], + .artifact-card__sub [data-card-size] { + overflow: hidden; + text-overflow: ellipsis; + } +} + +@media (prefers-reduced-motion: reduce) { + .artifact-toolbar__controls .artifact-toolbar__prefs { + transition: none; + } +} diff --git a/frontend/workspace/src/atlas/files/file-items.test.ts b/frontend/workspace/src/atlas/files/file-items.test.ts new file mode 100644 index 00000000..7199e4a0 --- /dev/null +++ b/frontend/workspace/src/atlas/files/file-items.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const css = () => readFileSync(fileURLToPath(new URL("./file-items.css", import.meta.url)), "utf8") + +describe("file catalog styles", () => { + test("keeps the selected grid visibly a grid at narrow pane widths", () => { + const styles = css() + + expect(styles).toMatch( + /@container files-pane \(max-width: 400px\)[\s\S]*\.artifact-grid\s*\{[^}]*display: grid;[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/, + ) + expect(styles).not.toMatch(/\.artifact-grid\s*\{[^}]*display: flex;[^}]*flex-direction: column/s) + }) + + test("uses the shared spacing and surface tokens without decorative borders", () => { + const styles = css() + + expect(styles).not.toContain("linear-gradient") + expect(styles).not.toContain("border: 1px") + expect(styles).toContain("var(--color-border-weak-base)") + expect(styles).toContain("var(--color-surface-solid)") + }) +}) diff --git a/frontend/workspace/src/atlas/files/sources.test.ts b/frontend/workspace/src/atlas/files/sources.test.ts index 5a27f126..0eae09a7 100644 --- a/frontend/workspace/src/atlas/files/sources.test.ts +++ b/frontend/workspace/src/atlas/files/sources.test.ts @@ -13,17 +13,18 @@ const grant = (id: string, path: string, access: "read" | "write"): FilesystemGr }) describe("pane sources", () => { - test("puts artifacts first, then the project, then granted folders", () => { + test("puts saved artifacts first, working files next, and recovery last", () => { const list = buildSources({ projectRoot: "/home/keertan/codes/openscience-demoo", projectName: "openscience-demoo", grants: [grant("g1", "/home/keertan/data/pdebench", "read")], }) - expect(list.map((s) => s.id)).toEqual(["artifacts", "trash", "project", "g1"]) - expect(list[0]?.group).toBe("Artifacts") - expect(list[2]?.group).toBe("This computer") - expect(list[2]?.sub).toBe("/home/keertan/codes/openscience-demoo") + expect(list.map((s) => s.id)).toEqual(["artifacts", "project", "g1", "trash"]) + expect(list[0]).toMatchObject({ group: "Saved", name: "Saved artifacts" }) + expect(list[1]?.group).toBe("Working files") + expect(list[1]?.sub).toBe("/home/keertan/codes/openscience-demoo") + expect(list.at(-1)?.group).toBe("Recovery") }) test("always offers trash so the delete dialog's 30-day recovery promise has a surface", () => { @@ -31,7 +32,8 @@ describe("pane sources", () => { const entry = list.find((s) => s.kind === "trash") expect(entry?.id).toBe("trash") - expect(entry?.group).toBe("Artifacts") + expect(entry?.group).toBe("Recovery") + expect(entry?.detail).toContain("30 days") }) test("marks a read grant read-only so the badge has something true to show", () => { @@ -51,12 +53,50 @@ describe("pane sources", () => { expect(without.some((s) => s.kind === "session")).toBe(false) expect(with_.find((s) => s.kind === "session")?.root).toBe("/p/.session") + expect(with_.find((s) => s.kind === "session")).toMatchObject({ + name: "Session workspace", + detail: "Scratch files for this session", + }) + }) + + test("does not relabel the project directory as session scratch space", () => { + const exact = buildSources({ + projectRoot: "/work/OpenScience", + projectName: "OpenScience", + grants: [], + sessionRoot: "/work/OpenScience", + }) + const normalized = buildSources({ + projectRoot: "/work/OpenScience/", + projectName: "OpenScience", + grants: [], + sessionRoot: "/work/./OpenScience", + }) + + expect(exact.filter((source) => source.kind === "session")).toHaveLength(0) + expect(normalized.filter((source) => source.kind === "session")).toHaveLength(0) + expect(exact.filter((source) => source.root === "/work/OpenScience")).toHaveLength(1) + }) + + test("keeps a genuinely distinct session workspace visible", () => { + const list = buildSources({ + projectRoot: "/work/OpenScience", + projectName: "OpenScience", + grants: [], + sessionRoot: "/app-data/workspaces/prj_1/ses_1", + }) + + expect(list.find((source) => source.kind === "session")).toMatchObject({ + name: "Session workspace", + detail: "Scratch files for this session", + root: "/app-data/workspaces/prj_1/ses_1", + }) }) test("groups in a fixed order and drops empty groups", () => { const groups = groupSources(buildSources({ projectRoot: "/p", projectName: "p", grants: [] })) - expect(groups.map((g) => g.group)).toEqual(["Artifacts", "This computer"]) + expect(groups.map((g) => g.group)).toEqual(["Saved", "Working files", "Recovery"]) }) // One entry per provider. Remote will hold AWS, GCP and the rest, and an @@ -68,7 +108,7 @@ describe("pane sources", () => { expect(remote).toHaveLength(1) expect(remote[0]!.id).toBe("modal") - expect(remote[0]!.name).toBe("Modal") + expect(remote[0]!.name).toBe("Modal Volumes") expect(remote[0]!.group).toBe("Remote") // Browsable and downloadable, never writable: it is an API, not a mount. expect(remote[0]!.readonly).toBe(true) @@ -86,7 +126,7 @@ describe("pane sources", () => { test("keeps remote sources after local ones so the picker order is stable", () => { const groups = groupSources(buildSources({ projectRoot: "/p", projectName: "p", grants: [], modal: true })) - expect(groups.map((g) => g.group)).toEqual(["Artifacts", "This computer", "Remote"]) + expect(groups.map((g) => g.group)).toEqual(["Saved", "Working files", "Remote", "Recovery"]) }) }) diff --git a/frontend/workspace/src/atlas/files/sources.ts b/frontend/workspace/src/atlas/files/sources.ts index 2abf0f1c..f779ae94 100644 --- a/frontend/workspace/src/atlas/files/sources.ts +++ b/frontend/workspace/src/atlas/files/sources.ts @@ -1,11 +1,18 @@ -import { fileSourceName, type FilesystemGrant } from "@/atlas/file-sources" +import { fileSourceName, normalizeFilePath, type FilesystemGrant } from "@/atlas/file-sources" -export type SourceGroup = "Artifacts" | "This computer" | "Remote" +/** + * The picker is an information architecture, not a list of storage backends. + * A saved deliverable, a working folder, and a recovery location have + * materially different lifetimes, so they do not share one ambiguous bucket. + */ +export type SourceGroup = "Saved" | "Working files" | "Remote" | "Recovery" export interface PaneSource { id: string group: SourceGroup name: string + /** A short, truthful description shown when a source has no useful path. */ + detail?: string sub?: string root: string kind: "artifacts" | "trash" | "project" | "session" | "connected" | "modal" @@ -13,7 +20,7 @@ export interface PaneSource { live?: boolean } -const ORDER: SourceGroup[] = ["Artifacts", "This computer", "Remote"] +const ORDER: SourceGroup[] = ["Saved", "Working files", "Remote", "Recovery"] export function buildSources(input: { projectRoot: string @@ -24,25 +31,33 @@ export function buildSources(input: { modal?: boolean }): PaneSource[] { const list: PaneSource[] = [ - { id: "artifacts", group: "Artifacts", name: "All artifacts", root: "", kind: "artifacts" }, - // Listed unconditionally: a trash entry that appears only once something - // is in it is a recovery path nobody can find in advance, and the delete - // dialog promises this surface before anything has been deleted. - { id: "trash", group: "Artifacts", name: "Trash", root: "", kind: "trash" }, + { + id: "artifacts", + group: "Saved", + name: "Saved artifacts", + detail: "Durable, versioned deliverables", + root: "", + kind: "artifacts", + }, { id: "project", - group: "This computer", + group: "Working files", name: input.projectName, sub: input.projectRoot, root: input.projectRoot, kind: "project", }, ] - if (input.sessionRoot) { + // Legacy sessions may report the project directory itself as their workspace + // grant. That is not isolated scratch space, and listing the same path twice + // under two lifetimes would be actively misleading. Only a distinct, + // normalized location earns the Session workspace source. + if (input.sessionRoot && normalizeFilePath(input.sessionRoot) !== normalizeFilePath(input.projectRoot)) { list.push({ id: "session", - group: "This computer", - name: "Session files", + group: "Working files", + name: "Session workspace", + detail: "Scratch files for this session", sub: input.sessionRoot, root: input.sessionRoot, kind: "session", @@ -51,7 +66,7 @@ export function buildSources(input: { for (const grant of input.grants) { list.push({ id: grant.id, - group: "This computer", + group: "Working files", name: fileSourceName(grant.path), sub: grant.path, root: grant.path, @@ -66,8 +81,28 @@ export function buildSources(input: { // It browses and downloads but never writes: the pane reaches Modal over its // API, not a mount, so there is nothing to save back through. if (input.modal) { - list.push({ id: "modal", group: "Remote", name: "Modal", sub: "Volumes", root: "", kind: "modal", readonly: true }) + list.push({ + id: "modal", + group: "Remote", + name: "Modal Volumes", + detail: "Connected remote storage", + root: "", + kind: "modal", + readonly: true, + }) } + + // Listed unconditionally and last: recovery should remain discoverable, but + // it should not sit between the primary saved destination and working files. + // The delete flow promises this location before the first item is deleted. + list.push({ + id: "trash", + group: "Recovery", + name: "Trash", + detail: "Recoverable for 30 days", + root: "", + kind: "trash", + }) return list } diff --git a/frontend/workspace/src/atlas/project-search.test.ts b/frontend/workspace/src/atlas/project-search.test.ts new file mode 100644 index 00000000..3e228c26 --- /dev/null +++ b/frontend/workspace/src/atlas/project-search.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { requestProjectSearch, type ProjectSearchHits } from "./project-search" + +const empty: ProjectSearchHits = { sessions: [], messages: [], artifacts: [] } + +describe("project search transport", () => { + test("keeps a genuine empty result distinct from an unavailable search", async () => { + const result = await requestProjectSearch( + async () => new Response(JSON.stringify(empty), { status: 200, headers: { "content-type": "application/json" } }), + ) + + expect(result).toEqual(empty) + }) + + test("rejects non-OK and unreachable searches so the UI can offer recovery", async () => { + await expect(requestProjectSearch(async () => new Response("offline", { status: 503 }))).rejects.toThrow( + "Search failed (503)", + ) + await expect(requestProjectSearch(async () => Promise.reject(new Error("network unavailable")))).rejects.toThrow( + "network unavailable", + ) + }) +}) diff --git a/frontend/workspace/src/atlas/project-search.ts b/frontend/workspace/src/atlas/project-search.ts new file mode 100644 index 00000000..f2c2dc35 --- /dev/null +++ b/frontend/workspace/src/atlas/project-search.ts @@ -0,0 +1,11 @@ +export interface ProjectSearchHits { + sessions: Array<{ id: string; title: string }> + messages: Array<{ sessionID: string; messageID: string; role: string; snippet: string }> + artifacts: Array<{ path: string; name: string; kind: string }> +} + +export async function requestProjectSearch(run: () => Promise<Response>): Promise<ProjectSearchHits> { + const response = await run() + if (!response.ok) throw new Error(`Search failed (${response.status})`) + return response.json() as Promise<ProjectSearchHits> +} diff --git a/frontend/workspace/src/atlas/project-workspace-lifecycle.fixture.tsx b/frontend/workspace/src/atlas/project-workspace-lifecycle.fixture.tsx new file mode 100644 index 00000000..15e02152 --- /dev/null +++ b/frontend/workspace/src/atlas/project-workspace-lifecycle.fixture.tsx @@ -0,0 +1,36 @@ +import { createSignal, onCleanup, onMount } from "solid-js" +import { render } from "solid-js/web" +import { ProjectWorkspaceFrame } from "./ProjectWorkspaceFrame" + +export function mountProjectWorkspaceLifecycle(host: HTMLElement) { + const [session, setSession] = createSignal("session-a") + const lifecycle = { mounts: 0, cleanups: 0 } + + const Surface = (props: { name: "terminal" | "file" }) => { + onMount(() => lifecycle.mounts++) + onCleanup(() => lifecycle.cleanups++) + return <section data-surface={props.name}>{props.name}</section> + } + + const dispose = render( + () => ( + <ProjectWorkspaceFrame + inspector={ + <aside data-inspector="project"> + <Surface name="terminal" /> + <Surface name="file" /> + </aside> + } + > + <span data-chat-session>Chat {session()}</span> + </ProjectWorkspaceFrame> + ), + host, + ) + + return { + setSession, + dispose, + lifecycle: () => ({ ...lifecycle }), + } +} diff --git a/frontend/workspace/src/atlas/project-workspace-lifecycle.test.ts b/frontend/workspace/src/atlas/project-workspace-lifecycle.test.ts new file mode 100644 index 00000000..79576b6c --- /dev/null +++ b/frontend/workspace/src/atlas/project-workspace-lifecycle.test.ts @@ -0,0 +1,54 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import { fileURLToPath } from "node:url" +import { createServer } from "vite" +import solidPlugin from "vite-plugin-solid" + +const server = await createServer({ + root: fileURLToPath(new URL("../..", import.meta.url)), + mode: "production", + logLevel: "silent", + plugins: [solidPlugin({ ssr: false, dev: false })], + server: { middlewareMode: true }, + appType: "custom", + resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, + ssr: { + noExternal: true, + resolve: { conditions: ["browser", "production"] }, + }, +}) + +const harness = (await server.ssrLoadModule( + "/src/atlas/project-workspace-lifecycle.fixture.tsx", +)) as typeof import("./project-workspace-lifecycle.fixture") + +const cleanups: Array<() => void> = [] + +afterAll(() => server.close()) + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + document.body.replaceChildren() +}) + +describe("project workspace lifecycle", () => { + test("switches session A to B without remounting open Terminal and File surfaces", async () => { + const host = document.createElement("div") + document.body.append(host) + const mounted = harness.mountProjectWorkspaceLifecycle(host) + cleanups.push(mounted.dispose) + + await Promise.resolve() + const terminal = host.querySelector('[data-surface="terminal"]') + const file = host.querySelector('[data-surface="file"]') + expect(host.textContent).toContain("Chat session-a") + expect(mounted.lifecycle()).toEqual({ mounts: 2, cleanups: 0 }) + + mounted.setSession("session-b") + await Promise.resolve() + + expect(host.textContent).toContain("Chat session-b") + expect(host.querySelector('[data-surface="terminal"]')).toBe(terminal) + expect(host.querySelector('[data-surface="file"]')).toBe(file) + expect(mounted.lifecycle()).toEqual({ mounts: 2, cleanups: 0 }) + }) +}) diff --git a/frontend/workspace/src/atlas/right-pane-artifact.test.ts b/frontend/workspace/src/atlas/right-pane-artifact.test.ts index 01290dd8..adbd1ca3 100644 --- a/frontend/workspace/src/atlas/right-pane-artifact.test.ts +++ b/frontend/workspace/src/atlas/right-pane-artifact.test.ts @@ -16,10 +16,11 @@ const server = await createServer({ resolve: { conditions: ["browser", "production"] }, }, }) -const [pane, artifacts, state, web] = await Promise.all([ +const [pane, artifacts, state, dialogs, web] = await Promise.all([ server.ssrLoadModule("/src/atlas/RightPane.tsx") as Promise<typeof import("./RightPane")>, server.ssrLoadModule("/src/artifacts/context.ts") as Promise<typeof import("@/artifacts/context")>, server.ssrLoadModule("/src/atlas/store/ui.ts") as Promise<typeof import("@/atlas/store/ui")>, + server.ssrLoadModule("@synsci/ui/context/dialog") as Promise<typeof import("@synsci/ui/context/dialog")>, server.ssrLoadModule("solid-js/web") as Promise<typeof import("solid-js/web")>, ]) const cleanups: Array<() => void> = [] @@ -104,7 +105,16 @@ test("wires the reactive artifact gate into RightPane", async () => { artifacts.artifactContext.clear(current.id) expect(state.uiStore.open()).toBe(true) - const host = mount(() => pane.RightPane()) + // RightPane now owns dirty-editor confirmation, so mounting the real pane + // must honor its DialogProvider contract even though this gate closes before + // any dialog is shown. + const host = mount(() => + dialogs.DialogProvider({ + get children() { + return pane.RightPane() + }, + }), + ) await Promise.resolve() expect(state.uiStore.open()).toBe(false) diff --git a/frontend/workspace/src/atlas/right-pane-files.test.ts b/frontend/workspace/src/atlas/right-pane-files.test.ts index 77284255..004613b3 100644 --- a/frontend/workspace/src/atlas/right-pane-files.test.ts +++ b/frontend/workspace/src/atlas/right-pane-files.test.ts @@ -19,10 +19,10 @@ test("keeps the explorer and selected file preview inside the contextual pane", expect(pane).toContain('display: browser() ? "flex" : "none"') expect(pane).toContain('if (context() === "files") setSeen(true)') expect(pane).toContain("<FileView") - expect(pane).toContain("directory={file.directory}") - expect(pane).toContain("path={file.path}") - expect(pane).toContain("onClose={() => uiStore.closeFile()}") - expect(pane).toContain("when={!file.external}") + expect(pane).toContain("directory={tab.file.directory}") + expect(pane).toContain("path={tab.file.path}") + expect(pane).toContain("onClose={() => void closeWorkTab(tab.id)}") + expect(pane).toContain("when={!tab.file.external}") expect(pane).toContain("<ExternalFileAccess") // Collection surfaces and individual files share one reorderable, // closable, project-scoped work strip that survives chat changes. @@ -31,7 +31,10 @@ test("keeps the explorer and selected file preview inside the contextual pane", expect(pane.indexOf("<WorkTabStrip")).toBeLessThan(pane.indexOf('class="research-inspector__controls"')) expect(pane).toContain("onSelect={uiStore.activateWorkTab}") expect(pane).toContain("onReorder={uiStore.moveWorkTab}") - expect(pane).toContain("<Show when={uiStore.file()} keyed>") + expect(pane).toContain("<For each={fileTabs()}>") + expect(pane).toContain("hidden={!visibleFile(tab)}") + expect(pane).toContain("onDirtyChange={(dirty) => markDirty(tab.id, dirty)}") + expect(pane).toContain('title: "Discard unsaved changes?"') expect(pane).toContain('subtitle="Session files"') expect(pane).toContain("<RightPaneGate>") expect(directory).toContain("uiStore.openFile(dir, path)") @@ -42,16 +45,19 @@ test("keeps the explorer and selected file preview inside the contextual pane", test("preserves the center conversation for markdown links while opening Files on the right", () => { const session = read("../pages/session.tsx") + const directory = read("../pages/directory-layout.tsx") + const projectPane = read("./ProjectRightPane.tsx") expect(session).toContain('data-component="conversation-center"') expect(session).toContain('aria-label="Conversation"') expect(session).toContain("uiStore.openFile(projectPath(), path)") expect(session).not.toContain("uiStore.closeFile()") - expect(session).toContain( - '<RightPane project={sdk.scope} session={params.id ?? "new"} onEnsureSession={ensureSession} />', - ) + expect(session).not.toContain("<RightPane") + expect(directory).toContain('<ProjectRightPane project={sdk.scope} session={params.id ?? "new"} />') + expect(projectPane).toContain("<RightPane project={props.project} session={props.session} />") expect(session).toContain('document.addEventListener("openscience:open-file", onOpenFile)') - expect(session).not.toContain('role="tabpanel"') + expect(session).toContain('id="session-conversation-panel"') + expect(session.match(/role="tabpanel"/g)).toHaveLength(1) expect(session).not.toContain("<CenterTabStrip") expect(session).not.toContain("centerTabs.docs()") }) diff --git a/frontend/workspace/src/atlas/right-pane-layout.test.ts b/frontend/workspace/src/atlas/right-pane-layout.test.ts index e25d96b2..fd29e1d1 100644 --- a/frontend/workspace/src/atlas/right-pane-layout.test.ts +++ b/frontend/workspace/src/atlas/right-pane-layout.test.ts @@ -3,10 +3,14 @@ import { DEFAULT_PANE_WIDTH, INLINE_PANE_BREAKPOINT, MAX_PANE_WIDTH, + MIN_CONVERSATION_WIDTH, MIN_PANE_WIDTH, clampPaneWidth, + equalPaneWidth, legacyPaneWidthKey, + maxPaneWidthForWorkspace, paneWidthForViewport, + paneWidthForWorkspace, paneWidthKey, readPaneWidth, savePaneWidth, @@ -26,6 +30,15 @@ describe("context pane layout", () => { expect(clampPaneWidth(512)).toBe(512) }) + test("can divide the available workspace evenly without crushing conversation", () => { + expect(MIN_CONVERSATION_WIDTH).toBe(360) + expect(equalPaneWidth(1200)).toBe(600) + expect(equalPaneWidth(1600)).toBe(800) + expect(equalPaneWidth(620)).toBe(MIN_PANE_WIDTH) + expect(maxPaneWidthForWorkspace(1200)).toBe(840) + expect(paneWidthForWorkspace(900, 1200)).toBe(840) + }) + test("keeps a true side pane at the reference desktop viewport without crushing the conversation", () => { expect(INLINE_PANE_BREAKPOINT).toBe(1100) expect(paneWidthForViewport(DEFAULT_PANE_WIDTH, 1100)).toBe(DEFAULT_PANE_WIDTH) @@ -58,7 +71,7 @@ describe("context pane layout", () => { const legacy = legacyPaneWidthKey("project-a", "session-a") values.set(legacy, "612") - expect(readPaneWidth(current, storage, [legacy])).toBe(MAX_PANE_WIDTH) + expect(readPaneWidth(current, storage, [legacy])).toBe(612) expect(values.get(current)).toBe("612") const blocked = { diff --git a/frontend/workspace/src/atlas/right-pane-layout.ts b/frontend/workspace/src/atlas/right-pane-layout.ts index 3dc20a09..bab22541 100644 --- a/frontend/workspace/src/atlas/right-pane-layout.ts +++ b/frontend/workspace/src/atlas/right-pane-layout.ts @@ -1,8 +1,9 @@ export const MIN_PANE_WIDTH = 320 -export const MAX_PANE_WIDTH = 600 +export const MAX_PANE_WIDTH = 960 export const DEFAULT_PANE_WIDTH = 400 export const INLINE_PANE_BREAKPOINT = 1100 export const INLINE_PANE_CHROME = 568 +export const MIN_CONVERSATION_WIDTH = 360 export function paneWidthKey(project: string) { return `openscience-context-width-v6:${encodeURIComponent(project)}` @@ -12,14 +13,27 @@ export function legacyPaneWidthKey(project: string, session = "new") { return `openscience-context-width-v5:${encodeURIComponent(project)}:${encodeURIComponent(session)}` } -export function clampPaneWidth(width: number) { - return Math.max(MIN_PANE_WIDTH, Math.min(MAX_PANE_WIDTH, width)) +export function maxPaneWidthForWorkspace(workspace: number) { + if (!Number.isFinite(workspace)) return MAX_PANE_WIDTH + return Math.max(MIN_PANE_WIDTH, Math.min(MAX_PANE_WIDTH, workspace - MIN_CONVERSATION_WIDTH)) +} + +export function clampPaneWidth(width: number, max = MAX_PANE_WIDTH) { + return Math.max(MIN_PANE_WIDTH, Math.min(max, width)) } export function paneWidthForViewport(width: number, viewport: number) { return clampPaneWidth(Math.min(width, viewport - INLINE_PANE_CHROME)) } +export function paneWidthForWorkspace(width: number, workspace: number) { + return clampPaneWidth(width, maxPaneWidthForWorkspace(workspace)) +} + +export function equalPaneWidth(workspace: number) { + return paneWidthForWorkspace(workspace / 2, workspace) +} + export function readPaneWidth( key: string, storage: Pick<Storage, "getItem" | "setItem"> = localStorage, diff --git a/frontend/workspace/src/atlas/right-pane-surface.test.ts b/frontend/workspace/src/atlas/right-pane-surface.test.ts index 1893139c..4ed881b3 100644 --- a/frontend/workspace/src/atlas/right-pane-surface.test.ts +++ b/frontend/workspace/src/atlas/right-pane-surface.test.ts @@ -4,10 +4,14 @@ import { fileURLToPath } from "node:url" const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), "utf8") -test("closed right pane renders no collapsed launcher and mounts terminal only as selected context", () => { +test("closed right pane renders no collapsed launcher and retains open file editors without showing the pane", () => { const source = read("./RightPane.tsx") + const styles = read("./right-pane-tabs.css") - expect(source).toContain("<Show when={uiStore.rightPaneOpen()}>") + expect(source).toContain("<Show when={uiStore.rightPaneOpen() || retained()}>") + expect(source).toContain('data-open={uiStore.rightPaneOpen() ? "true" : "false"}') + expect(styles).toContain('.right-pane-gate[data-open="false"]') + expect(styles).toContain("display: none") expect(source).not.toContain("CollapsedRail") expect(source).toContain('when={context() === "terminal"}') expect(source).toContain("<TerminalSurface />") @@ -15,6 +19,23 @@ test("closed right pane renders no collapsed launcher and mounts terminal only a expect(source).not.toContain("panel settings") }) +test("lives in a project-owned sibling frame instead of the session route", () => { + const frame = read("./ProjectWorkspaceFrame.tsx") + const frameStyles = read("./ProjectWorkspaceFrame.css") + const directory = read("../pages/directory-layout.tsx") + const session = read("../pages/session.tsx") + const styles = read("./right-pane-tabs.css") + + expect(frame).toContain('class="project-workspace-frame"') + expect(frame).toContain('class="project-workspace-frame__route"') + expect(directory).toContain('lazy(() => import("@/atlas/ProjectRightPane"))') + expect(directory).toContain("<ProjectRightPane") + expect(session).not.toContain("<RightPane") + expect(frameStyles).toContain(".project-workspace-frame") + expect(frameStyles).toContain(".project-workspace-frame__route") + expect(styles).not.toContain(".project-workspace-frame") +}) + test("artifact context keeps the pane header as its only close or back action", () => { const source = read("./RightPane.tsx") @@ -23,6 +44,16 @@ test("artifact context keeps the pane header as its only close or back action", expect(source).not.toContain("<ArtifactInspector context={current()} onClose=") }) +test("keeps modal focus inside visible content and moves focus with roving tabs", () => { + const source = read("./RightPane.tsx") + + expect(source).toContain(`!item.closest('[hidden], [aria-hidden="true"], [inert]')`) + expect(source).toContain("data-work-tab={tab.id}") + expect(source).toContain('event.currentTarget.closest(".inspector-tabs")') + expect(source).toContain(".find((item) => item.dataset.workTab === target.id)") + expect(source).toContain("?.focus()") +}) + test("resize separator exposes keyboard and range semantics", () => { const source = read("./RightPane.tsx") @@ -31,23 +62,35 @@ test("resize separator exposes keyboard and range semantics", () => { expect(source).toContain('event.key === "ArrowLeft"') expect(source).toContain('event.key === "ArrowRight"') expect(source).toContain("aria-valuemin={MIN_PANE_WIDTH}") - expect(source).toContain("aria-valuemax={MAX_PANE_WIDTH}") + expect(source).toContain("aria-valuemax={limit()}") expect(source).toContain("aria-valuenow={paneWidth()}") + expect(source).toContain("onDblClick={splitEvenly}") + expect(source).toContain('aria-label="Split workspace evenly"') + expect(source).toContain("new ResizeObserver(measure)") }) test("uses an inline desktop pane and a full-width narrow overlay, never a pane stacked below chat", () => { const source = read("./RightPane.tsx") const styles = read("../styles/atlas.css") + const paneStyles = read("./right-pane-tabs.css") expect(source).toContain("window.innerWidth < INLINE_PANE_BREAKPOINT") - expect(source).toContain("modal={narrow() || expanded()}") - expect(source).toContain("mobile={narrow()}") + expect(source).toContain("modal={uiStore.rightPaneOpen() && (narrow() || expanded())}") + expect(source).toContain("mobile={uiStore.rightPaneOpen() && narrow()}") expect(source).toContain("stacked={false}") expect(source).toContain("refs.prior = active instanceof HTMLElement ? active : undefined") expect(source).toContain("const prior = refs.modal ? refs.prior : undefined") expect(source).toContain("if (prior?.isConnected) queueMicrotask(() => prior.focus())") expect(styles).not.toContain('.session-right-pane[data-stacked="true"]') expect(styles).not.toContain("grid-template-rows: minmax(0, 45fr) minmax(0, 55fr)") + expect(styles).not.toContain(".session-right-pane") + expect(styles).not.toContain(".research-inspector__header") + expect(styles).not.toContain(".inspector-tabs") + expect(paneStyles).toContain("border-left: 1px solid var(--color-border)") + expect(paneStyles).toContain("border-bottom: 1px solid var(--color-border)") + expect(paneStyles).toContain("border-radius: var(--atlas-radius-xs)") + expect(paneStyles).not.toContain("linear-gradient") + expect(paneStyles).not.toContain("color-mix") }) test("mounts the unified compute surface for the kernels context", () => { @@ -55,7 +98,7 @@ test("mounts the unified compute surface for the kernels context", () => { expect(source).toContain('import { ComputeSurface } from "@/atlas/ComputeSurface"') expect(source).toContain('when={context() === "kernels"}') - expect(source).toContain("<ComputeSurface onEnsureSession={props.onEnsureSession} />") + expect(source).toContain("<ComputeSurface />") expect(source).not.toContain("<KernelPanel />") }) diff --git a/frontend/workspace/src/atlas/right-pane-tabs.css b/frontend/workspace/src/atlas/right-pane-tabs.css new file mode 100644 index 00000000..bc736858 --- /dev/null +++ b/frontend/workspace/src/atlas/right-pane-tabs.css @@ -0,0 +1,337 @@ +.right-pane-gate { + display: contents; +} + +.right-pane-gate[data-open="false"] { + display: none; +} + +.session-workspace { + isolation: isolate; + gap: 0; + padding: 0; + background: var(--color-bg); +} + +.session-main { + min-width: 0; + min-height: 0; + background: var(--color-bg); +} + +.session-right-pane { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + margin: 0; + overflow: hidden; + border: 0; + border-left: 1px solid var(--color-border); + border-radius: 0; + background: var(--color-bg); +} + +.session-right-pane[data-overlay="true"] { + border-left-color: var(--color-border-strong); + background: var(--color-surface-solid); + box-shadow: var(--atlas-shadow-float); +} + +.session-right-pane[data-expanded="true"] { + border-left: 0; +} + +.session-right-pane-backdrop { + appearance: none; + padding: 0; + border: 0; + background: light-dark(hsl(35 18% 12% / 0.2), hsl(24 14% 2% / 0.5)); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +/* The pane border is the single structural separator. The wide invisible + handle only strengthens that same line while it is being targeted. */ +.research-inspector__resize { + position: absolute; + top: 0; + bottom: 0; + left: -8px; + z-index: 5; + width: 16px; + border: 0; + background: transparent; + cursor: ew-resize; + touch-action: none; +} + +.research-inspector__resize::before { + content: ""; + position: absolute; + inset: 0 auto 0 8px; + width: 1px; + background: transparent; + transition: background-color 140ms var(--agent-ease); +} + +.research-inspector__resize:hover::before, +.research-inspector__resize:focus-visible::before, +.research-inspector__resize:active::before { + background: var(--color-border-strong); +} + +.research-inspector__resize:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -3px; +} + +.research-inspector__header { + box-sizing: border-box; + min-width: 0; + min-height: 44px; + height: 44px; + display: flex; + align-items: stretch; + flex: 0 0 44px; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); +} + +.research-inspector__context { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + padding: 0 var(--space-3); +} + +.research-inspector__context strong { + min-width: 0; + overflow: hidden; + color: var(--color-text); + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 1.3; + letter-spacing: -0.005em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.research-inspector__controls { + position: relative; + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 2px; + padding: 6px; +} + +.research-inspector__control { + appearance: none; + box-sizing: border-box; + width: 32px; + min-width: 32px; + height: 32px; + display: inline-grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--color-text-faint); + cursor: pointer; + transition: + background-color 140ms var(--agent-ease), + color 140ms var(--agent-ease), + transform 120ms var(--agent-ease); +} + +.research-inspector__control:hover { + background: var(--surface-base-hover); + color: var(--color-text); +} + +.research-inspector__control:active { + background: var(--surface-base-active); + transform: scale(0.96); +} + +.research-inspector__control:focus-visible, +.inspector-tab:focus-visible, +.inspector-tab__close:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} + +.inspector-tabs { + min-width: 0; + height: 44px; + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 2px; + padding: 6px 4px 6px 8px; + overflow-x: auto; + overflow-y: hidden; + border: 0; + scrollbar-width: none; +} + +.inspector-tabs::-webkit-scrollbar { + display: none; +} + +.inspector-tab-pair { + min-width: 88px; + max-width: 208px; + min-height: 32px; + display: inline-flex; + align-items: stretch; + flex: 0 1 208px; + overflow: hidden; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--color-text-muted); + transition: + background-color 140ms var(--agent-ease), + color 140ms var(--agent-ease); +} + +.inspector-tab-pair:hover { + background: var(--surface-base-hover); + color: var(--color-text); +} + +.inspector-tab-pair[data-active="true"] { + background: var(--surface-base-active); + color: var(--color-text); +} + +.inspector-tab-pair .inspector-tab { + appearance: none; + min-width: 0; + min-height: 32px; + flex: 1 1 auto; + display: inline-flex; + align-items: center; + padding: 0 4px 0 10px; + overflow: hidden; + border: 0; + border-radius: 0; + background: transparent; + color: currentColor; + font-family: var(--font-family-sans); + font-size: 12.5px; + font-weight: var(--font-weight-regular); + line-height: 1.3; + letter-spacing: -0.005em; + cursor: pointer; + white-space: nowrap; + transition: transform 120ms var(--agent-ease); +} + +.inspector-tab-pair .inspector-tab:active { + transform: scale(0.985); +} + +.inspector-tab__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.inspector-tab-pair .inspector-tab__close { + appearance: none; + width: 32px; + min-width: 32px; + min-height: 32px; + display: inline-grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--color-text-faint); + cursor: pointer; + opacity: 0; + transition: + background-color 140ms var(--agent-ease), + color 140ms var(--agent-ease), + opacity 140ms var(--agent-ease), + transform 120ms var(--agent-ease); +} + +.inspector-tab-pair:hover .inspector-tab__close, +.inspector-tab-pair:focus-within .inspector-tab__close, +.inspector-tab-pair[data-active="true"] .inspector-tab__close { + opacity: 0.72; +} + +.inspector-tab-pair .inspector-tab__close:hover { + background: var(--surface-base-active); + color: var(--color-text); + opacity: 1; +} + +.inspector-tab-pair .inspector-tab__close:active { + transform: scale(0.92); +} + +@media (max-width: 719px) { + .session-workspace { + display: flex !important; + } + + .session-right-pane { + border: 0; + } + + .research-inspector__header { + min-height: 48px; + height: 48px; + flex-basis: 48px; + } + + .inspector-tabs { + height: 48px; + padding-block: 2px; + } +} + +@media (pointer: coarse) { + .research-inspector__controls { + padding-block: 0; + } + + .research-inspector__control, + .inspector-tab-pair, + .inspector-tab-pair .inspector-tab, + .inspector-tab-pair .inspector-tab__close { + min-height: 44px; + } + + .research-inspector__control, + .inspector-tab-pair .inspector-tab__close { + width: 44px; + min-width: 44px; + } +} + +@media (pointer: coarse) and (min-width: 720px) { + .inspector-tabs { + padding-block: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .research-inspector__resize::before, + .research-inspector__control, + .inspector-tab-pair, + .inspector-tab-pair .inspector-tab, + .inspector-tab-pair .inspector-tab__close { + transition: none; + } +} diff --git a/frontend/workspace/src/atlas/shared/AgentIcon.tsx b/frontend/workspace/src/atlas/shared/AgentIcon.tsx index 12c91c82..233e1dbb 100644 --- a/frontend/workspace/src/atlas/shared/AgentIcon.tsx +++ b/frontend/workspace/src/atlas/shared/AgentIcon.tsx @@ -1,4 +1,5 @@ import { type JSX } from "solid-js" +import { IconResearch } from "./Icon" interface AgentIconProps { size?: number @@ -23,19 +24,7 @@ export function AgentIcon(props: AgentIconProps): JSX.Element { }} aria-hidden="true" > - <svg - width={size()} - height={size()} - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width={strokeWidth()} - stroke-linecap="round" - > - <ellipse cx="12" cy="12" rx="10" ry="4" /> - <ellipse cx="12" cy="12" rx="4" ry="10" /> - <circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" /> - </svg> + <IconResearch size={size()} strokeWidth={strokeWidth()} /> </span> ) } diff --git a/frontend/workspace/src/atlas/shared/Icon.test.ts b/frontend/workspace/src/atlas/shared/Icon.test.ts new file mode 100644 index 00000000..9f413278 --- /dev/null +++ b/frontend/workspace/src/atlas/shared/Icon.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = readFileSync(fileURLToPath(new URL("./Icon.tsx", import.meta.url)), "utf8") + +test("workspace icons normalize size and weight through the shared adapter", () => { + expect(source).toContain("if (value <= 12) return 12") + expect(source).toContain("if (value <= 16) return 16") + expect(source).toContain("if (value <= 18) return 18") + expect(source).toContain("return 20") + expect(source).toContain("Math.min(1.75, Math.max(1.5, value))") + expect(source).toContain('"--icon-stroke-width": weight(props.strokeWidth)') +}) + +test("workspace semantics map to dedicated shared glyphs", () => { + expect(source).toContain('IconLayoutGrid = icon("layout-grid")') + expect(source).toContain('IconSplit = icon("split")') + expect(source).toContain('IconCpu = icon("cpu")') + expect(source).toContain('IconFolderTree = icon("folder-tree")') + expect(source).toContain('IconRefresh = icon("refresh")') + expect(source).toContain('IconFlask = icon("flask")') + expect(source).toContain('IconFile = icon("file")') + expect(source).toContain('IconAtom = icon("atom")') + expect(source).toContain('IconNetwork = icon("network")') + expect(source).toContain('IconArtifact = icon("artifact")') + expect(source).toContain('IconResearch = icon("research")') + expect(source).not.toContain('IconSplit = icon("task")') + expect(source).not.toContain('IconNetwork = icon("branch")') + expect(source).not.toContain('IconCpu = icon("providers")') + expect(source).not.toContain('IconFile = icon("code-lines")') +}) + +test("agent identity uses the research-specific mark", () => { + const agent = readFileSync(fileURLToPath(new URL("./AgentIcon.tsx", import.meta.url)), "utf8") + + expect(agent).toContain('import { IconResearch } from "./Icon"') + expect(agent).toContain("<IconResearch") + expect(agent).not.toContain("<IconAtom") +}) diff --git a/frontend/workspace/src/atlas/shared/Icon.tsx b/frontend/workspace/src/atlas/shared/Icon.tsx index 7797f45e..c408ed58 100644 --- a/frontend/workspace/src/atlas/shared/Icon.tsx +++ b/frontend/workspace/src/atlas/shared/Icon.tsx @@ -10,57 +10,80 @@ interface IconProps { type Name = CanonicalProps["name"] +const size = (value = 16) => { + if (value <= 12) return 12 + if (value <= 16) return 16 + if (value <= 18) return 18 + return 20 +} + +const weight = (value = 1.5) => Math.min(1.75, Math.max(1.5, value)) + const icon = (name: Name) => - (props: IconProps): JSX.Element => ( - <Icon - name={name} - size={!props.size || props.size <= 16 ? "small" : props.size <= 20 ? "normal" : "medium"} - class={props.class} - style={{ - width: props.size ? `${props.size}px` : undefined, - height: props.size ? `${props.size}px` : undefined, - ...props.style, - }} - /> - ) + (props: IconProps): JSX.Element => { + const pixels = () => size(props.size) + const scale = () => (pixels() <= 16 ? "small" : pixels() === 18 ? "normal" : "medium") + return ( + <Icon + name={name} + size={scale()} + class={props.class} + style={{ + width: pixels() === 12 ? "12px" : undefined, + height: pixels() === 12 ? "12px" : undefined, + "--icon-stroke-width": weight(props.strokeWidth), + ...props.style, + }} + /> + ) + } -export const IconLayoutGrid = icon("layout-right") -export const IconCpu = icon("providers") -export const IconBraces = icon("code") -export const IconFolderTree = icon("folder") -export const IconRefresh = icon("undo") +export const IconLayoutGrid = icon("layout-grid") +export const IconSplit = icon("split") +export const IconCpu = icon("cpu") +export const IconBraces = icon("braces") +export const IconFolderTree = icon("folder-tree") +export const IconRefresh = icon("refresh") export const IconPlus = icon("plus") export const IconChevronRight = icon("chevron-right") export const IconChevronDown = icon("chevron-down") -export const IconChevronLeft = icon("arrow-left") +export const IconChevronLeft = icon("chevron-left") export const IconX = icon("close") export const IconArrowUp = icon("arrow-up") export const IconArrowRight = icon("arrow-right") export const IconStop = icon("stop") export const IconSettings = icon("settings-gear") -export const IconHome = icon("layout-left") -export const IconFlask = icon("models") -export const IconFile = icon("code-lines") +export const IconHome = icon("home") +export const IconFlask = icon("flask") +export const IconFile = icon("file") export const IconFolder = icon("folder") export const IconUpload = icon("cloud-upload") -export const IconSparkles = icon("models") -export const IconBookOpen = icon("bullet-list") -export const IconActivity = icon("task") -export const IconClock = icon("task") +export const IconSparkles = icon("sparkles") +export const IconBookOpen = icon("book-open") +export const IconActivity = icon("activity") +export const IconClock = icon("clock") export const IconCheckCircle = icon("circle-check") -export const IconAlertCircle = icon("circle-x") +export const IconAlertCircle = icon("alert-circle") export const IconMessageSquare = icon("speech-bubble") -export const IconNetwork = icon("branch") +export const IconNetwork = icon("network") export const IconTerminal = icon("console") export const IconBrain = icon("brain") -export const IconAtom = icon("models") +export const IconResearch = icon("research") +export const IconArtifact = icon("artifact") +export const IconDatabase = icon("database") +export const IconTable = icon("table") +export const IconAtom = icon("atom") export const IconSearch = icon("magnifying-glass") -export const IconPaperclip = icon("link") -export const IconMoon = icon("circle-ban-sign") -export const IconSun = icon("models") -export const IconStar = icon("models") -export const IconStarFilled = icon("models") +export const IconPaperclip = icon("paperclip") +export const IconMicrophone = icon("microphone") +export const IconShield = icon("shield") +export const IconShieldAlert = icon("shield-alert") +export const IconBolt = icon("bolt") +export const IconMoon = icon("moon") +export const IconSun = icon("sun") +export const IconStar = icon("star") +export const IconStarFilled = icon("star-filled") export const IconPin = icon("pin") export const IconPinFilled = icon("pin-filled") export const IconExpand = icon("expand") @@ -69,8 +92,9 @@ export const IconTrash = icon("trash") export const IconShare = icon("share") export const IconDownload = icon("download") export const IconCopy = icon("copy") +export const IconEdit = icon("pencil-line") export const IconArchive = icon("archive") -export const IconMoreH = icon("dot-grid") +export const IconMoreH = icon("more-horizontal") export const IconLink = icon("link") export const IconServer = icon("server") export const IconCloud = icon("cloud") diff --git a/frontend/workspace/src/atlas/shared/StatusDot.tsx b/frontend/workspace/src/atlas/shared/StatusDot.tsx index e1ece03a..925bb628 100644 --- a/frontend/workspace/src/atlas/shared/StatusDot.tsx +++ b/frontend/workspace/src/atlas/shared/StatusDot.tsx @@ -1,5 +1,4 @@ import { type JSX } from "solid-js" -import { FONT_MONO } from "@/styles/tokens" export type StatusKind = "active" | "pending" | "error" | "done" | "muted" @@ -17,32 +16,26 @@ const COLOR: Record<StatusKind, string> = { muted: "var(--color-text-faint)", } -const CHAR: Record<StatusKind, string> = { - active: "●", - pending: "◐", - error: "×", - done: "○", - muted: "·", -} - export function StatusDot(props: StatusDotProps): JSX.Element { const size = () => props.size ?? 11 + const outlined = () => props.status === "pending" || props.status === "done" return ( <span aria-hidden="true" class={props.pulse ? "atlas-pulse" : undefined} + data-status={props.status} style={{ - "font-family": FONT_MONO, - "font-size": `${size()}px`, color: COLOR[props.status], - width: `${size() - 1}px`, - "text-align": "center", + width: `${size()}px`, + height: `${size()}px`, + border: outlined() ? "1.25px solid currentColor" : "1.25px solid transparent", + "border-radius": "999px", + "background-color": outlined() ? "transparent" : "currentColor", + opacity: props.status === "muted" ? 0.6 : 1, + "box-sizing": "border-box", "flex-shrink": 0, - "line-height": 1, display: "inline-block", }} - > - {CHAR[props.status]} - </span> + /> ) } diff --git a/frontend/workspace/src/atlas/skill-permissions.ts b/frontend/workspace/src/atlas/skill-permissions.ts new file mode 100644 index 00000000..69f06fe1 --- /dev/null +++ b/frontend/workspace/src/atlas/skill-permissions.ts @@ -0,0 +1,91 @@ +export type SkillPermissionAction = "allow" | "ask" | "deny" + +const isAction = (value: unknown): value is SkillPermissionAction => + value === "allow" || value === "ask" || value === "deny" + +export function skillAction(permission: unknown, name: string): SkillPermissionAction { + if (!permission || typeof permission !== "object" || Array.isArray(permission)) return "allow" + const skill = (permission as Record<string, unknown>).skill + if (isAction(skill)) return skill + if (!skill || typeof skill !== "object" || Array.isArray(skill)) return "allow" + const rules = skill as Record<string, unknown> + const exact = rules[name] + if (isAction(exact)) return exact + const wildcard = rules["*"] + return isAction(wildcard) ? wildcard : "allow" +} + +export function skillPermissionChange(permission: unknown, name: string, enabled: boolean) { + const base = permission && typeof permission === "object" && !Array.isArray(permission) ? permission : {} + const existing = (base as Record<string, unknown>).skill + const rules: Record<string, SkillPermissionAction> = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record<string, SkillPermissionAction>) + : isAction(existing) + ? { "*": existing } + : {} + const skill = { ...rules, [name]: enabled ? ("allow" as const) : ("deny" as const) } + return { + optimistic: { ...(base as Record<string, unknown>), skill }, + patch: { skill }, + } +} + +/** Restore one exact rule without erasing newer optimistic changes. */ +export function restoreExactSkillPermission(current: unknown, before: unknown, name: string) { + const base = current && typeof current === "object" && !Array.isArray(current) ? current : {} + const currentSkill = (base as Record<string, unknown>).skill + const rules: Record<string, SkillPermissionAction> = + currentSkill && typeof currentSkill === "object" && !Array.isArray(currentSkill) + ? { ...(currentSkill as Record<string, SkillPermissionAction>) } + : isAction(currentSkill) + ? { "*": currentSkill } + : {} + + const previousSkill = + before && typeof before === "object" && !Array.isArray(before) + ? (before as Record<string, unknown>).skill + : undefined + const previousRules = + previousSkill && typeof previousSkill === "object" && !Array.isArray(previousSkill) + ? (previousSkill as Record<string, unknown>) + : undefined + const previousExact = previousRules?.[name] + + if (isAction(previousExact)) rules[name] = previousExact + else delete rules[name] + + return { ...(base as Record<string, unknown>), skill: rules } as Record<string, unknown> & { + skill: Record<string, SkillPermissionAction> + } +} + +export async function commitSkillPermission( + name: string, + enabled: boolean, + hooks: { + isBusy: () => boolean + permission: () => unknown + setPermission: (permission: unknown) => void + setBusy: (busy: boolean) => void + write: (patch: Record<string, unknown>) => Promise<unknown> + }, +): Promise<{ ok: true } | { ok: false; busy: true } | { ok: false; error: string }> { + // updateConfig patches permission state, but a failed optimistic write has + // to restore a whole snapshot. Serialize toggles so that rollback can never + // erase a different skill change that completed in the meantime. + if (hooks.isBusy()) return { ok: false, busy: true } + const before = hooks.permission() + const change = skillPermissionChange(before, name, enabled) + hooks.setBusy(true) + hooks.setPermission(change.optimistic) + try { + await hooks.write(change.patch) + return { ok: true } + } catch (error) { + hooks.setPermission(before) + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + hooks.setBusy(false) + } +} diff --git a/frontend/workspace/src/atlas/skills-page.css b/frontend/workspace/src/atlas/skills-page.css new file mode 100644 index 00000000..d16c4306 --- /dev/null +++ b/frontend/workspace/src/atlas/skills-page.css @@ -0,0 +1,704 @@ +.skills-workspace { + container: skills-workspace / inline-size; + display: flex; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family-sans); +} + +.skills-workspace[data-layout="settings"] { + background: transparent; +} + +.skills-workspace__header { + flex: 0 0 auto; + padding: 24px 28px; + border-bottom: 1px solid var(--color-border); + background: transparent; +} + +.skills-workspace__heading, +.skills-workspace__toolbar, +.skills-workspace__content { + width: min(100%, 1080px); + margin-inline: auto; +} + +.skills-workspace__heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.skills-workspace__heading-copy { + min-width: 0; +} + +.skills-workspace__heading h1, +.skills-workspace__heading h2 { + margin: 0; + color: var(--color-text); + font-family: var(--font-family-sans); + font-weight: var(--font-weight-emphasis); + line-height: 1.2; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.skills-workspace__heading h1 { + font-size: 24px; +} + +.skills-workspace__heading h2 { + font-size: 18px; +} + +.skills-workspace__heading p { + max-width: 580px; + margin: 4px 0 0; + color: var(--color-text-muted); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1.45; + text-wrap: pretty; +} + +.skills-workspace__summary { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + color: var(--color-text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-regular); + white-space: nowrap; +} + +.skills-workspace__summary > span[aria-hidden="true"] { + color: var(--color-text-faint); +} + +.skills-workspace__toolbar { + margin-top: 16px; +} + +.skills-workspace__toolbar-controls { + display: grid; + min-width: 0; + grid-template-columns: minmax(220px, 1fr) minmax(128px, 0.42fr) minmax(148px, 0.48fr) max-content; + align-items: center; + gap: 8px; +} + +.skills-workspace__toolbar-controls > .settings-control { + width: 100%; + min-width: 0; + max-width: 100%; +} + +.skills-workspace__toolbar-controls > button.settings-control { + justify-content: space-between; +} + +.skills-workspace .settings-control { + min-height: 32px; + height: 32px; + border-color: transparent; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text); + font-size: 12px; + font-weight: var(--font-weight-medium); + box-shadow: none; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.skills-workspace .settings-control:hover, +.skills-workspace .settings-control[data-expanded] { + border-color: transparent; + background: var(--color-surface-solid); +} + +.skills-workspace .settings-control:focus-within, +.skills-workspace .settings-control:focus-visible { + border-color: transparent; + outline: 2px solid var(--color-border-strong); + outline-offset: 1px; +} + +.skills-workspace .settings-control:active { + transform: scale(0.98); +} + +.skills-workspace .settings-toolbar > label { + width: 100%; + min-width: 0; +} + +.skills-workspace .settings-toolbar > label input { + min-width: 0; + color: var(--color-text); + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1; +} + +.skills-workspace .settings-toolbar > label input::placeholder { + color: var(--color-text-faint); + opacity: 1; +} + +.skills-workspace__body { + min-width: 0; + min-height: 0; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 24px 28px 48px; +} + +.skills-workspace__content { + min-width: 0; +} + +.skills-workspace__list { + display: flex; + flex-direction: column; + gap: var(--settings-space-5, 24px); +} + +.skills-workspace__group { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.skills-workspace__group-heading { + display: flex; + min-height: 24px; + align-items: center; + gap: 8px; + padding-inline: 2px; +} + +.skills-workspace__group-heading h3 { + margin: 0; + color: var(--color-text); + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-emphasis); + line-height: 1.35; + text-wrap: balance; +} + +.skills-workspace__group-heading > span { + color: var(--color-text-faint); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-regular); +} + +.skills-workspace__rows { + display: flex; + overflow: visible; + flex-direction: column; + gap: var(--settings-space-1, 4px); + margin: 0; + padding: 0; + border: 0; + border-radius: var(--settings-radius-card, var(--atlas-radius-md)); + background: transparent; + list-style: none; + isolation: isolate; +} + +.skills-workspace__row { + position: relative; + display: grid; + min-width: 0; + min-height: 60px; + grid-template-columns: minmax(190px, 0.9fr) minmax(260px, 1.4fr) 32px; + align-items: center; + gap: 12px; + padding: 8px 12px; + border: 0; + border-radius: var(--settings-radius-control, var(--atlas-radius-sm)); + background: var(--color-bg-elevated); + transition: background-color 150ms ease; +} + +.skills-workspace__row:hover { + background: var(--color-surface-solid); +} + +.skills-workspace__identity { + display: block; + min-width: 0; +} + +.skills-workspace__form-icon, +.skills-workspace__state-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.skills-workspace__identity-copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.skills-workspace__identity strong { + overflow: hidden; + color: var(--color-text); + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-emphasis); + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-workspace__identity-copy > span { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; + overflow: hidden; + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.3; + white-space: nowrap; +} + +.skills-workspace__identity code { + overflow: hidden; + color: var(--color-text-muted); + font-family: var(--font-family-mono); + font-size: 12px; + font-variant-numeric: slashed-zero; + text-overflow: ellipsis; +} + +.skills-workspace__details { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; +} + +.skills-workspace__details > p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--color-text-muted); + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1.45; + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.skills-workspace__details > p[data-empty="true"] { + color: var(--color-text-faint); + font-style: italic; +} + +.skills-workspace__tags { + display: flex; + min-width: 0; + max-width: none; + align-items: center; + gap: 4px; + overflow: visible; +} + +.skills-workspace__tags span { + min-width: max-content; + max-width: none; + flex: 0 0 auto; + overflow: visible; + padding: 3px 7px; + border: 0; + border-radius: var(--settings-radius-pill, 999px); + background: var(--color-surface-solid); + color: var(--color-text); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 1.25; + white-space: nowrap; +} + +.skills-workspace__more { + min-height: 32px; + align-self: center; + padding: 0 12px; + border: 0; + border-radius: var(--settings-radius-control, var(--atlas-radius-sm)); + background: var(--color-bg-elevated); + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-medium); + transition: + background-color 150ms ease, + color 150ms ease; +} + +.skills-workspace__more:hover { + background: var(--color-surface-solid); + color: var(--color-text); +} + +.skills-workspace__toggle { + display: flex; + min-width: 32px; + justify-content: flex-end; +} + +.skills-workspace [data-component="switch"] { + min-width: 32px; + min-height: 32px; + justify-content: flex-end; +} + +.skills-workspace [data-component="switch"] [data-slot="switch-control"] { + width: 32px; + height: 18px; + border-radius: var(--settings-radius-pill, 999px); +} + +.skills-workspace [data-component="switch"] [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + margin-inline: 1px; + border-width: 0; + border-radius: 50%; +} + +.skills-workspace [data-component="switch"][data-checked] [data-slot="switch-thumb"] { + transform: translateX(14px); +} + +.skills-workspace__row[data-enabled="false"] .skills-workspace__identity, +.skills-workspace__row[data-enabled="false"] .skills-workspace__details { + opacity: 0.6; +} + +.skills-workspace__state { + display: flex; + min-height: 260px; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 48px 24px; + text-align: center; +} + +.skills-workspace__state-icon { + width: 44px; + height: 44px; + margin-bottom: 12px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text-muted); +} + +.skills-workspace__state > strong { + color: var(--color-text); + font-size: 14px; + font-weight: var(--font-weight-emphasis); + line-height: 1.35; +} + +.skills-workspace__state > p { + max-width: 420px; + margin: 4px 0 0; + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.5; + text-wrap: pretty; +} + +.skills-workspace__state > button { + box-sizing: border-box; + min-height: 32px; + margin-top: 16px; + padding: 0 12px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text); + cursor: pointer; + font-size: 13px; + font-weight: var(--font-weight-medium); + transition: background-color 150ms ease; +} + +.skills-workspace__state > button:hover { + background: var(--color-surface-solid); +} + +.skills-workspace__state > button:active { + transform: scale(0.98); +} + +.skills-workspace__state > button:focus-visible { + outline: 2px solid var(--color-border-strong); + outline-offset: 2px; +} + +.skills-workspace__form { + display: flex; + width: min(100%, 720px); + flex-direction: column; + gap: 16px; +} + +.skills-workspace__form-heading { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.skills-workspace__form-icon { + width: 32px; + height: 32px; + flex: 0 0 auto; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text-muted); +} + +.skills-workspace__form-heading h3 { + margin: 0; + color: var(--color-text); + font-size: 16px; + font-weight: var(--font-weight-emphasis); + line-height: 1.35; + text-wrap: balance; +} + +.skills-workspace__form-heading p { + margin: 4px 0 0; + color: var(--color-text-muted); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 1.45; + text-wrap: pretty; +} + +.skills-workspace__form-fields { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + border: 0; + border-radius: var(--atlas-radius-md); + background: var(--color-bg-elevated); +} + +.skills-workspace__form-fields > label > span { + color: var(--color-text); + font-size: 12px; + font-weight: var(--font-weight-medium); +} + +.skills-workspace__form-fields input, +.skills-workspace__form-fields textarea { + border-color: transparent; + border-radius: var(--atlas-radius-sm); + background: var(--color-surface-solid); + color: var(--color-text); + font-size: 13px; + font-weight: var(--font-weight-regular); +} + +.skills-workspace__form-fields input { + height: 36px; +} + +.skills-workspace__form-fields input:focus, +.skills-workspace__form-fields textarea:focus { + border-color: transparent; + outline: 2px solid var(--color-border-strong); + outline-offset: 1px; +} + +.skills-workspace__form-fields input::placeholder, +.skills-workspace__form-fields textarea::placeholder { + color: var(--color-text-faint); + opacity: 1; +} + +.skills-workspace__security-note { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding: 12px; + border: 0; + border-radius: var(--atlas-radius-sm); + background: var(--color-surface-solid); + color: var(--color-text-muted); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.5; + text-wrap: pretty; +} + +.skills-workspace__security-note [data-component="icon"] { + flex: 0 0 auto; + color: var(--color-text-muted); +} + +.skills-workspace__form-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.skills-workspace__form-actions button { + min-height: 32px; + border-radius: var(--atlas-radius-sm); + font-size: 13px; + font-weight: var(--font-weight-medium); +} + +@container skills-workspace (max-width: 800px) { + .skills-workspace__heading { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .skills-workspace__toolbar-controls { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .skills-workspace .settings-toolbar > label { + grid-column: 1 / -1; + } + + .skills-workspace .settings-toolbar > button { + width: 100%; + max-width: 100%; + justify-content: space-between; + } + + .skills-workspace .settings-toolbar > button[aria-label="Add skill"] { + justify-content: center; + } + + .skills-workspace__row { + grid-template-columns: minmax(0, 1fr) 32px; + gap: 8px 12px; + } + + .skills-workspace__details { + display: grid; + grid-column: 1 / -1; + gap: 6px; + padding-left: 0; + } + + .skills-workspace__details > p { + -webkit-line-clamp: 1; + } + + .skills-workspace__tags { + min-width: 0; + max-width: none; + justify-content: flex-start; + flex-wrap: wrap; + overflow: visible; + } + + .skills-workspace__toggle { + grid-column: 2; + grid-row: 1; + } +} + +@container skills-workspace (max-width: 640px) { + .skills-workspace__header { + padding: 24px 20px 16px; + } + + .skills-workspace__heading h1 { + font-size: 21px; + } + + .skills-workspace__body { + padding: 16px 20px 32px; + } + + .skills-workspace__details { + padding-left: 0; + } + + .skills-workspace__tags { + display: none; + } +} + +@container skills-workspace (max-width: 460px) { + .skills-workspace__toolbar-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .skills-workspace .settings-toolbar > button[aria-label="Add skill"] { + grid-column: 1 / -1; + } + + .skills-workspace__form-fields { + padding: 16px; + } + + .skills-workspace__form-actions { + align-items: stretch; + flex-direction: column; + } + + .skills-workspace__form-actions button { + width: 100%; + } +} + +@media (pointer: coarse) { + .skills-workspace .settings-control, + .skills-workspace [data-component="switch"], + .skills-workspace__state > button, + .skills-workspace__form-actions button { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .skills-workspace .settings-control, + .skills-workspace__row, + .skills-workspace__state > button { + transition: none; + } +} diff --git a/frontend/workspace/src/atlas/skills-page.test.ts b/frontend/workspace/src/atlas/skills-page.test.ts index 55d1b00a..80faad30 100644 --- a/frontend/workspace/src/atlas/skills-page.test.ts +++ b/frontend/workspace/src/atlas/skills-page.test.ts @@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url" import { installFromGit } from "./skills-settings" const source = () => readFileSync(fileURLToPath(new URL("./SkillsPage.tsx", import.meta.url)), "utf8") +const styles = () => readFileSync(fileURLToPath(new URL("./skills-page.css", import.meta.url)), "utf8") +const settingsStyles = () => + readFileSync(fileURLToPath(new URL("../components/settings/skills.css", import.meta.url)), "utf8") test("skills use a compact searchable list instead of a card dashboard", () => { const page = source() @@ -16,6 +19,114 @@ test("skills use a compact searchable list instead of a card dashboard", () => { expect(page).not.toContain('"grid-template-columns": "repeat(auto-fill') }) +test("skills catalog keeps readable type, aligned controls, and semantic states", () => { + const page = source() + const css = styles() + + expect(page).toContain('placeholder="Search skills"') + expect(page).toContain('label: "All categories"') + expect(page).toContain('icon="alert-circle"') + expect(page).toContain('action="Try again"') + expect(page).toContain("CATEGORY_ICON") + expect(page).toContain("skillIconFor") + expect(page).toContain("words === words.toUpperCase()") + expect(css).toContain("font-size: 14px") + expect(css).toContain("font-size: 13px") + expect(css).toContain("font-size: 12px") + expect(css).toContain("@media (pointer: coarse)") + expect(css).toContain("min-height: 44px") + expect(css).toContain("background: var(--color-surface-solid)") + expect(css).not.toContain("text-transform") + expect(css).not.toContain("!important") +}) + +test("skills catalog is intrinsically responsive inside narrow panes", () => { + const css = styles() + + expect(css).toMatch(/\.skills-workspace\s*\{[^}]*container: skills-workspace \/ inline-size/s) + expect(css).toMatch(/\.skills-workspace\s*\{[^}]*min-width: 0/s) + expect(css).toMatch(/\.skills-workspace__body\s*\{[^}]*overflow-x: hidden/s) + expect(css).toMatch( + /\.skills-workspace__row\s*\{[^}]*grid-template-columns: minmax\(190px, 0\.9fr\) minmax\(260px, 1\.4fr\) 32px/s, + ) + expect(css).toContain("@container skills-workspace (max-width: 800px)") + expect(css).toContain("@container skills-workspace (max-width: 460px)") + expect(css).not.toContain("minmax(210px") + expect(css).not.toContain("minmax(250px") +}) + +test("skills surfaces share settings radii and flat grouped states", () => { + const css = styles() + const settings = settingsStyles() + + expect(css).toContain("border-radius: var(--settings-radius-card, var(--atlas-radius-md))") + expect(css).toContain("border-radius: var(--settings-radius-control, var(--atlas-radius-sm))") + expect(css).toContain("border-radius: var(--atlas-radius-sm)") + expect(settings).toContain("border-radius: var(--settings-radius-control)") + expect(css).toMatch(/\.skills-workspace__row:hover\s*\{[^}]*background: var\(--color-surface-solid\)/s) + expect(css).toMatch(/\.skills-workspace__rows\s*\{[^}]*gap: var\(--settings-space-1, 4px\)[^}]*border: 0/s) + expect(css).not.toContain(".skills-workspace__row + .skills-workspace__row::before") + + for (const surface of [css, settings]) { + expect(surface).not.toMatch(/border-radius:\s*\d+(?:\.\d+)?px/) + expect(surface).not.toMatch(/border(?:-(?:top|right|bottom|left))?:\s*[^;\n]*color-mix/) + } + + expect(css).not.toContain("color-mix") +}) + +test("embedded skills use the restrained settings surface stack", () => { + const page = source() + const settings = settingsStyles() + + expect(page).not.toContain('class="skills-workspace__source-icon"') + expect(page).toContain('class="skills-workspace__details"') + expect(settings).toContain("background: var(--settings-surface-muted)") + expect(settings).toContain("background: var(--settings-surface-hover)") + expect(settings).toContain("border-color: transparent") + expect(settings).toContain("outline: 1px solid var(--settings-border-strong)") + expect(settings).not.toContain("var(--settings-accent)") + expect(settings).not.toMatch(/(?:background|border-color):\s*(?:#000(?:000)?|black)/) +}) + +test("settings skills bound hidden DOM and make toggle feedback immediate", () => { + const page = source() + + expect(page).toContain("INITIAL_SKILL_ROWS = 56") + expect(page).toContain("SKILL_ROW_BATCH = 56") + expect(page).toContain("visibleShelves") + expect(page).toContain("if (panel.hidden) setVisibleRows(INITIAL_SKILL_ROWS)") + expect(page).toContain("sessionStorage.setItem(SKILL_CACHE_KEY") + expect(page).toContain('sync.set("config", "permission", change.optimistic') + expect(page).toContain("permissionWrites = permissionWrites.then") + expect(page).not.toContain("disabled={permissionBusy()}") +}) + +test("skill tags remain whole and switches keep compact geometry", () => { + const css = styles() + + expect(css).toMatch( + /\.skills-workspace__details\s*\{[^}]*display: grid;[^}]*grid-template-columns: minmax\(0, 1fr\) max-content/s, + ) + expect(css).toMatch(/\.skills-workspace__tags span\s*\{[^}]*min-width: max-content;[^}]*flex: 0 0 auto/s) + expect(css).toMatch( + /@container skills-workspace \(max-width: 640px\)[\s\S]*?\.skills-workspace__tags\s*\{[^}]*display: none/s, + ) + expect(css).toMatch( + /\.skills-workspace \[data-component="switch"\] \[data-slot="switch-control"\]\s*\{[^}]*width: 32px;[^}]*height: 18px/s, + ) + expect(css).toMatch(/\.skills-workspace \[data-component="switch"\]\s*\{[^}]*min-width: 32px;[^}]*min-height: 32px/s) +}) + +test("skill icons use subject metadata with a stable fallback", () => { + const page = source() + + expect(page).toContain('biology: "activity"') + expect(page).toContain('databases: "server"') + expect(page).toContain('"cloud-compute": "cloud"') + expect(page).toContain("for (const char of category || skill.name)") +}) + test("global skill installation does not select a filesystem project", async () => { const calls: Array<{ url: URL; init?: RequestInit }> = [] const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/frontend/workspace/src/atlas/skills-permission.test.ts b/frontend/workspace/src/atlas/skills-permission.test.ts new file mode 100644 index 00000000..42e72f9b --- /dev/null +++ b/frontend/workspace/src/atlas/skills-permission.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { + commitSkillPermission, + restoreExactSkillPermission, + skillAction, + skillPermissionChange, +} from "./skill-permissions" + +describe("skill Settings permission controls", () => { + test("respects a wildcard skill denial and lets an exact override win", () => { + expect(skillAction({ skill: "deny" }, "literature-review")).toBe("deny") + expect(skillAction({ skill: { "*": "deny", biology: "allow" } }, "biology")).toBe("allow") + expect(skillAction({ skill: { "*": "deny", biology: "allow" } }, "physics")).toBe("deny") + }) + + test("preserves an ask-by-default skill rule while adding an exact toggle", () => { + const change = skillPermissionChange({ skill: "ask" }, "biology", false) + + expect(skillAction({ skill: "ask" }, "physics")).toBe("ask") + expect(change.patch.skill).toEqual({ "*": "ask", biology: "deny" }) + }) + + test("builds a persistence patch without dropping unrelated permissions", () => { + const change = skillPermissionChange({ bash: "ask", skill: { "*": "deny" } }, "biology", true) + + expect(change.patch).toEqual({ skill: { "*": "deny", biology: "allow" } }) + expect(change.optimistic as Record<string, unknown>).toEqual({ + bash: "ask", + skill: { "*": "deny", biology: "allow" }, + }) + }) + + test("disabling one skill preserves every existing exact rule", () => { + const change = skillPermissionChange({ skill: { biology: "allow", physics: "allow" } }, "biology", false) + + expect(change.patch.skill).toEqual({ biology: "deny", physics: "allow" }) + }) + + test("serializes optimistic writes and rolls a failed write back", async () => { + let permission: unknown = { skill: { biology: "allow" } } + let busy = false + let release!: () => void + const pending = new Promise<void>((resolve) => (release = resolve)) + const hooks = { + isBusy: () => busy, + permission: () => permission, + setPermission: (next: unknown) => (permission = next), + setBusy: (next: boolean) => (busy = next), + write: async () => pending, + } + + const first = commitSkillPermission("biology", false, hooks) + expect(await commitSkillPermission("physics", false, hooks)).toEqual({ ok: false, busy: true }) + expect(skillAction(permission, "biology")).toBe("deny") + release() + expect(await first).toEqual({ ok: true }) + + hooks.write = async () => { + throw new Error("config write failed") + } + expect(await commitSkillPermission("biology", true, hooks)).toEqual({ ok: false, error: "config write failed" }) + expect(skillAction(permission, "biology")).toBe("deny") + }) + + test("rolls back one failed skill without erasing a newer optimistic change", () => { + const before: Record<string, unknown> = { bash: "ask", skill: { "*": "allow", biology: "allow" } } + const current: Record<string, unknown> = { + bash: "ask", + skill: { "*": "allow", biology: "deny", physics: "deny" }, + } + + expect(restoreExactSkillPermission(current, before, "biology")).toEqual({ + bash: "ask", + skill: { "*": "allow", biology: "allow", physics: "deny" }, + }) + expect(restoreExactSkillPermission(current, { skill: { "*": "allow" } }, "biology")).toEqual({ + bash: "ask", + skill: { "*": "allow", physics: "deny" }, + }) + }) +}) diff --git a/frontend/workspace/src/atlas/store/sessionTabs.test.ts b/frontend/workspace/src/atlas/store/sessionTabs.test.ts index 31edb41a..08c536a5 100644 --- a/frontend/workspace/src/atlas/store/sessionTabs.test.ts +++ b/frontend/workspace/src/atlas/store/sessionTabs.test.ts @@ -10,19 +10,17 @@ function memoryStorage(): SessionTabStorage { } describe("session tabs", () => { - test("reuses recent sessions through rapid switches without duplicates", () => { + test("reuses rapid session switches without duplicates", () => { const tabs = createSessionTabs({ storage: memoryStorage() }) tabs.activateProject("project-a") - for (let index = 0; index < 20; index++) { - tabs.open(index % 2 ? "session-a" : "session-b") - } + for (let index = 0; index < 20; index++) tabs.open(index % 2 ? "session-a" : "session-b") expect(tabs.tabs()).toEqual(["session-b", "session-a"]) expect(tabs.active()).toBe("session-a") }) - test("closing a tab only changes the strip and restores the nearest session after reload", () => { + test("closes to the nearest tab and restores order, active state, and drafts", () => { const storage = memoryStorage() const first = createSessionTabs({ storage }) first.activateProject("project-a") @@ -41,7 +39,7 @@ describe("session tabs", () => { expect(restored.dirty("session-b")).toBe(true) }) - test("never shares open sessions between projects", () => { + test("never shares tab state between projects", () => { const tabs = createSessionTabs({ storage: memoryStorage() }) tabs.activateProject("project-a") tabs.open("shared-session") @@ -57,7 +55,7 @@ describe("session tabs", () => { expect(tabs.dirty("shared-session")).toBe(true) }) - test("move reorders tabs, clamps targets, and persists across reload", () => { + test("reorders tabs, clamps targets, and persists the result", () => { const storage = memoryStorage() const tabs = createSessionTabs({ storage }) tabs.activateProject("project-a") @@ -67,22 +65,17 @@ describe("session tabs", () => { tabs.move("session-c", 0) expect(tabs.tabs()).toEqual(["session-c", "session-a", "session-b"]) - tabs.move("session-c", 99) expect(tabs.tabs()).toEqual(["session-a", "session-b", "session-c"]) - tabs.move("session-b", -5) expect(tabs.tabs()).toEqual(["session-b", "session-a", "session-c"]) - tabs.move("missing", 0) - expect(tabs.tabs()).toEqual(["session-b", "session-a", "session-c"]) - - const reloaded = createSessionTabs({ storage }) - reloaded.activateProject("project-a") - expect(reloaded.tabs()).toEqual(["session-b", "session-a", "session-c"]) + const restored = createSessionTabs({ storage }) + restored.activateProject("project-a") + expect(restored.tabs()).toEqual(["session-b", "session-a", "session-c"]) }) - test("tracks unread activity per project without confusing drafts or streaming state", () => { + test("tracks unread updates separately from drafts and active working state", () => { const storage = memoryStorage() const tabs = createSessionTabs({ storage }) tabs.activateProject("project-a") @@ -102,10 +95,5 @@ describe("session tabs", () => { restored.activateProject("project-a") restored.open("session-b") expect(restored.unread("session-a", 101)).toBe(false) - - restored.activateProject("project-b") - restored.open("session-a") - restored.open("session-b") - expect(restored.unread("session-a", 1)).toBe(true) }) }) diff --git a/frontend/workspace/src/atlas/store/ui.test.ts b/frontend/workspace/src/atlas/store/ui.test.ts index e6c5aa99..5e64f78f 100644 --- a/frontend/workspace/src/atlas/store/ui.test.ts +++ b/frontend/workspace/src/atlas/store/ui.test.ts @@ -59,9 +59,13 @@ describe("context pane state", () => { first.activateScope("project-a", "session-a") first.openFile("/work/a", "results/a.csv") first.setArtifactPaneTab("history") + const workTabs = first.workTabs() + const openFile = first.file() first.activateScope("project-a", "session-b") expect(first.open()).toBe(true) + expect(first.workTabs()).toBe(workTabs) + expect(first.file()).toBe(openFile) expect(first.file()?.path).toBe("results/a.csv") expect(first.artifactPaneTab()).toBe("history") first.openContext("kernels") @@ -133,7 +137,7 @@ describe("context pane state", () => { expect(state.prefill()).toBe("alpha") }) - test("active context toggles closed and a different context switches directly", () => { + test("active context remains selected and a different context switches directly", () => { const state = createContextState() state.openContext("files") @@ -141,14 +145,15 @@ describe("context pane state", () => { expect(state.open()).toBe(true) state.openContext("files") - expect(state.open()).toBe(false) + expect(state.open()).toBe(true) + expect(state.activeWorkTab()).toBe("view:files") state.openContext("kernels") expect(state.context()).toBe("kernels") expect(state.open()).toBe(true) }) - test("Files returns from a selected preview to the project source browser before toggling", () => { + test("Files returns from a selected preview to the project source browser without implicitly closing", () => { const state = createContextState() state.openFile("/work/alpha", "results/curve.csv") @@ -160,7 +165,8 @@ describe("context pane state", () => { expect(state.file()).toBeUndefined() state.openContext("files") - expect(state.open()).toBe(false) + expect(state.open()).toBe(true) + expect(state.activeWorkTab()).toBe("view:files") }) test("persists the terminal as a project context", () => { @@ -411,14 +417,15 @@ describe("open-file tabs", () => { expect(state.files().map((file) => file.path)).toEqual(["c.txt", "a.md", "b.csv"]) }) - test("a full strip evicts the oldest inactive tab", () => { + test("opening a ninth file never silently evicts an existing editor", () => { const state = createContextState({ storage: memoryStorage() }) state.activateScope("project-a", "session-a") for (let index = 0; index < 9; index++) state.openFile("/root", `file-${index}.md`) - expect(state.files()).toHaveLength(8) + expect(state.files()).toHaveLength(9) expect(state.file()?.path).toBe("file-8.md") - expect(state.files().some((file) => file.path === "file-0.md")).toBe(false) + expect(state.files().some((file) => file.path === "file-0.md")).toBe(true) + expect(state.workTabs().filter((tab) => tab.kind === "file")).toHaveLength(9) }) test("tabs persist per scope and returning to Files keeps them with no active file", () => { diff --git a/frontend/workspace/src/atlas/store/ui.ts b/frontend/workspace/src/atlas/store/ui.ts index d3d4c16b..0a3ab427 100644 --- a/frontend/workspace/src/atlas/store/ui.ts +++ b/frontend/workspace/src/atlas/store/ui.ts @@ -148,8 +148,6 @@ function restoreFile(value: unknown) { return file } -const MAX_FILE_TABS = 8 - function fileKey(file: ContextFile) { return `${file.directory}\n${file.path}` } @@ -216,7 +214,6 @@ function restoreState(value: unknown): ContextState { .map(restoreFile) .filter((item): item is ContextFile => item !== undefined) .filter((item, index, all) => all.findIndex((other) => fileKey(other) === fileKey(item)) === index) - .slice(0, MAX_FILE_TABS) // The active file is always one of the tabs — sessions persisted before // tabs existed carry only `file`. if (file && !files.some((item) => fileKey(item) === fileKey(file))) files.unshift(file) @@ -403,10 +400,10 @@ export function createContextState(options: { storage?: ContextStorage } = {}) { update(select(state, tabs, id)) return } - if (state.open && state.activeWorkTab === id) { - closeContext() - return - } + // Context actions select; they do not also act as an implicit close. + // Closing is owned by the inspector header, where dirty file drafts can + // be confirmed before the mounted editors are released. + if (state.open && state.activeWorkTab === id) return update(select(state, tabs, id)) } const openFile = (directory: string, path: string) => { @@ -414,17 +411,10 @@ export function createContextState(options: { storage?: ContextStorage } = {}) { const file = resolveContextFile(directory, path) const existing = state.files ?? [] const known = existing.some((item) => fileKey(item) === fileKey(file)) - const expanded = known ? existing : [...existing, file] - // A full strip evicts the oldest tab that is not the active file. - const evict = - !known && expanded.length > MAX_FILE_TABS - ? expanded.find((item) => !(state.file && fileKey(item) === fileKey(state.file))) - : undefined - const files = evict ? expanded.filter((item) => fileKey(item) !== fileKey(evict)) : expanded - const withoutEvicted = evict - ? (state.workTabs ?? []).filter((item) => item.kind !== "file" || fileKey(item.file) !== fileKey(evict)) - : (state.workTabs ?? []) - const tabs = ensure(ensure(withoutEvicted, viewTab("files")), fileTab(file)) + const files = known ? existing : [...existing, file] + // Never evict an open editor implicitly. Its draft lives in the mounted + // FileView, so the user closes tabs explicitly after saving or confirming. + const tabs = ensure(ensure(state.workTabs ?? [], viewTab("files")), fileTab(file)) update(select(state, tabs, fileTab(file).id, files)) } const closeFile = (path?: string) => { diff --git a/frontend/workspace/src/atlas/use-execution-authority.ts b/frontend/workspace/src/atlas/use-execution-authority.ts index aba5f0e8..eb842391 100644 --- a/frontend/workspace/src/atlas/use-execution-authority.ts +++ b/frontend/workspace/src/atlas/use-execution-authority.ts @@ -1,5 +1,5 @@ import { useParams } from "@solidjs/router" -import { createMemo, createResource, onCleanup, type Accessor } from "solid-js" +import { createMemo, createResource, createSignal, onCleanup, type Accessor } from "solid-js" import { useSDK } from "@/context/sdk" import { createExecutionAuthorityAPI, @@ -20,6 +20,7 @@ export function useExecutionAuthority(capability: ExecutionCapability | Accessor return { projectID, sessionID, capability: current() } }) const [decision, controls] = createResource(input, api.inspect) + const [trusting, setTrusting] = createSignal(false) const refresh = () => { if (!input()) return void controls.refetch() @@ -56,12 +57,30 @@ export function useExecutionAuthority(capability: ExecutionCapability | Accessor value.capability === expected.capability ) }) + const canTrust = createMemo(() => { + const value = decision() + return !decision.error && !decision.loading && value?.reason === "project_untrusted" && !!value.remediation + }) + const trustProject = async () => { + const value = decision() + if (!value || !canTrust() || trusting()) throw new Error("Project trust is not currently available.") + setTrusting(true) + try { + await api.trust(value) + await controls.refetch() + } finally { + setTrusting(false) + } + } return { decision, allowed, loading: () => decision.loading, message, + canTrust, + trusting, + trust: trustProject, refetch: refresh, } } diff --git a/frontend/workspace/src/atlas/use-kernel-list.test.ts b/frontend/workspace/src/atlas/use-kernel-list.test.ts index f275348e..71662142 100644 --- a/frontend/workspace/src/atlas/use-kernel-list.test.ts +++ b/frontend/workspace/src/atlas/use-kernel-list.test.ts @@ -51,7 +51,7 @@ afterEach(() => { // as a metric value, so freshness assertions read this one field instead of // the whole card's textContent. const executions = (element: Element | null) => - element?.querySelector('[data-slot="kernel-card-executions"]')?.textContent?.trim().split(" ")[0] + element?.querySelector('[data-slot="kernel-card-executions"]')?.textContent?.match(/· (\d+) cells?/)?.[1] const mount = (view: () => JSX.Element) => { const host = document.createElement("div") @@ -143,8 +143,10 @@ describe("kernel list reconciliation", () => { // on a nested object (`resources`) rather than on the kernel itself — so // this pins that reconcile's patch reaches them and the head re-renders, // rather than the card holding the first reading it was given. - const usage = (element: Element | null) => - [...(element?.querySelectorAll(".kernel-card__metric") ?? [])].map((metric) => metric.textContent).join(" · ") + const metric = (element: Element | null, label: string) => + [...(element?.querySelectorAll(".kernel-card__metric") ?? [])] + .find((item) => item.querySelector("small")?.textContent === label) + ?.querySelector("strong")?.textContent const [source, setSource] = core.createSignal<KernelStatus[] | undefined>([ kernel({ id: "kernel-a", resources: { cpu_percent: 0, memory_bytes: 16_000_000 } }), ]) @@ -152,15 +154,15 @@ describe("kernel list reconciliation", () => { await Promise.resolve() const card = host.querySelector('[data-kernel-id="kernel-a"]') - expect(usage(card)).toContain("16 MB") - expect(usage(card)).toContain("0.0cores") + expect(metric(card, "Memory")).toBe("16 MB") + expect(metric(card, "CPU cores")).toBe("0.0") setSource([kernel({ id: "kernel-a", resources: { cpu_percent: 187.5, memory_bytes: 2_400_000_000 } })]) await Promise.resolve() expect(host.querySelector('[data-kernel-id="kernel-a"]')).toBe(card) - expect(usage(card)).toContain("2.4 GB") - expect(usage(card)).toContain("1.9cores") + expect(metric(card, "Memory")).toBe("2.4 GB") + expect(metric(card, "CPU cores")).toBe("1.9") }) test("mounts a newly appeared kernel and unmounts one that disappeared", async () => { diff --git a/frontend/workspace/src/atlas/useGlobalKeys.test.ts b/frontend/workspace/src/atlas/useGlobalKeys.test.ts index b1fe20d1..9025cc7e 100644 --- a/frontend/workspace/src/atlas/useGlobalKeys.test.ts +++ b/frontend/workspace/src/atlas/useGlobalKeys.test.ts @@ -10,3 +10,11 @@ test("Cmd-K opens project search even while the composer is focused", () => { expect(typing).toBeGreaterThan(shortcut) expect(source.slice(shortcut, typing)).toContain("uiStore.setPaletteOpen(true)") }) + +test("global launch shortcuts stay inert behind an open palette or help modal", () => { + const modalGuard = source.indexOf("uiStore.paletteOpen() || uiStore.helpOpen()") + const shortcut = source.indexOf('if (mod && key === "k")') + + expect(modalGuard).toBeGreaterThan(0) + expect(shortcut).toBeGreaterThan(modalGuard) +}) diff --git a/frontend/workspace/src/atlas/useGlobalKeys.ts b/frontend/workspace/src/atlas/useGlobalKeys.ts index 114cf559..27e794d8 100644 --- a/frontend/workspace/src/atlas/useGlobalKeys.ts +++ b/frontend/workspace/src/atlas/useGlobalKeys.ts @@ -12,6 +12,7 @@ export function useGlobalKeys(input: { onNew?: () => void }) { const dialog = useDialog() const onKeyDown = (event: KeyboardEvent) => { if (dialog.active) return + if (uiStore.paletteOpen() || uiStore.helpOpen()) return const mod = event.metaKey || event.ctrlKey const key = event.key.toLowerCase() if (mod && key === "k") { diff --git a/frontend/workspace/src/atlas/workspace-shell-polish.test.ts b/frontend/workspace/src/atlas/workspace-shell-polish.test.ts new file mode 100644 index 00000000..d8c2b5ca --- /dev/null +++ b/frontend/workspace/src/atlas/workspace-shell-polish.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" + +const read = (path: string) => Bun.file(new URL(path, import.meta.url)).text() + +describe("seamless workspace shell", () => { + test("aligns workspace chrome while giving the navigation rail a stable 48px grip", async () => { + const [header, tabs, pane, sidebar] = await Promise.all([ + read("../pages/session-header.css"), + read("../pages/session-tabs.css"), + read("./right-pane-tabs.css"), + read("../pages/session-sidebar.css"), + ]) + + expect(header).toMatch(/\.workspace-header\.g-strip\s*\{[^}]*height: 44px;[^}]*flex: 0 0 44px;/s) + expect(tabs).toMatch(/\.workspace-session-tabs\s*\{[^}]*height: 44px;/s) + expect(pane).toMatch(/\.research-inspector__header\s*\{[^}]*height: 44px;[^}]*flex: 0 0 44px;/s) + expect(sidebar).toMatch(/\.session-sidebar__top\s*\{[^}]*height: 48px;[^}]*flex: 0 0 48px;/s) + }) + + test("uses only structural pane separators and quiet pill tabs", async () => { + const [tabs, pane, sidebar] = await Promise.all([ + read("../pages/session-tabs.css"), + read("./right-pane-tabs.css"), + read("../pages/session-sidebar.css"), + ]) + + expect(sidebar).toMatch( + /\.session-sidebar\s*\{[^}]*border-right: 1px solid color-mix\(in srgb, var\(--color-border\) 72%, transparent\);/s, + ) + expect(pane).toMatch(/\.session-right-pane\s*\{[^}]*border-left: 1px solid var\(--color-border\);/s) + expect(sidebar).toMatch(/\.session-sidebar__context-actions\s*\{[^}]*border: 0;/s) + expect(tabs).toMatch(/\.workspace-session-tab\s*\{[^}]*border: 0;/s) + expect(pane).toMatch(/\.inspector-tab-pair\s*\{[^}]*border: 0;/s) + expect(pane).not.toContain("color-mix") + }) + + test("keeps controls comfortably targeted with restrained feedback", async () => { + const [tabs, pane, sidebar, source] = await Promise.all([ + read("../pages/session-tabs.css"), + read("./right-pane-tabs.css"), + read("../pages/session-sidebar.css"), + read("./RightPane.tsx"), + ]) + + for (const css of [tabs, pane, sidebar]) { + expect(css).toContain("min-height: 32px") + expect(css).toContain("140ms var(--agent-ease)") + expect(css).toContain("@media (pointer: coarse)") + expect(css).toContain("min-height: 44px") + expect(css).toContain(":active") + expect(css).toContain("scale(") + } + expect(source).toContain('role="tablist" aria-orientation="horizontal"') + }) +}) diff --git a/frontend/workspace/src/components/chat-keyboard-focus.test.ts b/frontend/workspace/src/components/chat-keyboard-focus.test.ts new file mode 100644 index 00000000..ef54d087 --- /dev/null +++ b/frontend/workspace/src/components/chat-keyboard-focus.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const atlas = () => readFileSync(fileURLToPath(new URL("../styles/atlas.css", import.meta.url)), "utf8") +const shell = () => readFileSync(fileURLToPath(new URL("../../index.html", import.meta.url)), "utf8") + +test("chat and composer controls retain the shared visible keyboard focus ring", () => { + const css = atlas() + + expect(css).toMatch(/:where\(button, \[role="button"\], a, input, textarea, select, \[tabindex\]\):focus-visible/) + expect(css).not.toMatch(/\.session-scroller[^{}]*:focus-visible\s*\{[^}]*outline:\s*none\s*!important/s) + expect(css).not.toMatch( + /\[data-component="prompt-input"\][^{}]*:focus-visible\s*\{[^}]*outline:\s*none\s*!important/s, + ) +}) + +test("the browser shell uses the warm dark palette as its pre-script fallback", () => { + const html = shell() + + expect(html).toContain('<meta name="theme-color" content="#26241f" />') + expect(html).not.toContain('media="(prefers-color-scheme: dark)"') + expect(html).not.toContain('content="#171717"') +}) diff --git a/frontend/workspace/src/components/chat-surface.css b/frontend/workspace/src/components/chat-surface.css new file mode 100644 index 00000000..19c883b2 --- /dev/null +++ b/frontend/workspace/src/components/chat-surface.css @@ -0,0 +1,419 @@ +/* Conversation surface + -------------------- + The transcript and composer share one centered content edge. User messages + remain visually distinct without becoming heavy cards, while transcript + actions sit beside content instead of covering it. */ + +[data-component="conversation-center"] { + /* The dock height is replaced with its measured value by the session shell. + These fallbacks keep first paint and non-ResizeObserver environments safe. */ + --workspace-composer-height: calc(116px + env(safe-area-inset-bottom)); + --workspace-composer-clearance: var(--space-5); + --workspace-composer-reserve: calc(var(--workspace-composer-height) + var(--workspace-composer-clearance)); + container-name: conversation; + container-type: inline-size; +} + +.session-prompt-dock { + padding: 10px 16px calc(14px + env(safe-area-inset-bottom)); +} + +.session-prompt-dock__inner { + width: min(100%, 740px); + padding: 0; +} + +/* SessionTurn defaults to a full-height standalone view. In the transcript it + must size to its content so one turn never creates a viewport of dead space. */ +.atlas-chat-scroll [data-component="session-turn"] { + height: auto; + min-height: 0; + display: block; +} + +.atlas-chat-scroll [data-component="session-turn"] > [data-slot="session-turn-content"] { + height: auto; + min-height: 0; + flex-grow: 0; + overflow-x: visible; + overflow-y: visible; +} + +.session-transcript { + width: min(100%, 772px); + margin-inline: auto; + padding-top: 18px; +} + +.session-scroller [data-slot="session-turn-sticky"] { + position: static; + width: 100%; + margin-left: 0; + padding-left: 0; + background: transparent; +} + +.session-scroller [data-slot="session-turn-sticky"]::before, +.session-scroller [data-slot="session-turn-sticky"]::after { + display: none; +} + +.session-scroller [data-slot="session-turn-message-content"] { + width: 100%; + max-width: 100%; + margin-left: 0; +} + +.session-scroller [data-slot="session-turn-message-container"] { + gap: 12px; + padding-inline: 16px; +} + +.session-turn-divider { + height: 24px; +} + +.session-scroller [data-component="user-message"] { + --user-message-surface: var(--color-accent-subtle, var(--surface-base-hover)); + --user-message-radius: var(--radius-lg); + --user-message-tail-radius: var(--radius-xs); +} + +.session-scroller [data-slot="user-message-row"] { + align-items: flex-start; +} + +.session-scroller [data-component="user-message"] [data-slot="user-message-text"] { + width: fit-content; + max-width: min(76%, 580px); + margin-left: 0; + padding: 9px 12px; + border: 0; + border-radius: var(--user-message-radius) var(--user-message-radius) var(--user-message-tail-radius) + var(--user-message-radius); + background: var(--user-message-surface); + box-shadow: none; + color: var(--color-text); + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: 1.5; +} + +.session-scroller [data-slot="user-message-copy-wrapper"] { + margin-top: 1px; + color: var(--color-text-faint, var(--text-weaker)); +} + +.session-scroller [data-slot="user-message-copy-wrapper"] [data-component="icon-button"] { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + border-radius: var(--radius-xs); +} + +.session-scroller [data-slot="user-message-copy-wrapper"] [data-slot="icon-svg"] { + color: currentColor; +} + +.session-scroller [data-slot="user-message-copy-wrapper"]:hover, +.session-scroller [data-slot="user-message-copy-wrapper"]:focus-within { + color: var(--color-text-muted, var(--text-weak)); +} + +.session-scroller [data-component="assistant-message"] { + gap: 8px; + font-weight: var(--font-weight-regular); +} + +.session-scroller [data-slot="session-turn-markdown"], +.session-scroller [data-slot="session-turn-markdown"] [data-component="markdown"] { + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: 1.58; + font-optical-sizing: auto; + font-synthesis: none; + text-wrap: pretty; +} + +/* A completed turn with file diffs used to jump to the shared 15px treatment, + making otherwise identical assistant replies look larger. Keep one stable + reading size regardless of whether a turn happened to edit files. */ +.session-scroller [data-component="session-turn"] [data-slot="session-turn-markdown"][data-diffs="true"] { + font-size: 14px; +} + +/* Compact document rhythm for long research answers. Hierarchy comes from + weight, color, and proximity instead of oversized type or large dead zones. */ +.session-scroller [data-slot="session-turn-markdown"] :is(h1, h2, h3, h4, h5, h6) { + margin-block: 18px 7px; + color: var(--text-strong); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: 1.35; + text-wrap: balance; +} + +.session-scroller [data-slot="session-turn-markdown"] :is(h1, h2) { + font-size: 15px; +} + +.session-scroller [data-slot="session-turn-markdown"] p { + margin-bottom: 10px; +} + +.session-scroller [data-slot="session-turn-markdown"] :is(ul, ol) { + margin-block: 6px 10px; + padding-left: 20px; +} + +.session-scroller [data-slot="session-turn-markdown"] li { + margin-bottom: 4px; +} + +.session-scroller [data-slot="session-turn-markdown"] blockquote { + margin-block: 12px; + padding-left: 10px; +} + +.session-scroller [data-slot="session-turn-markdown"] :is(pre, img) { + margin-block: 16px; +} + +/* Model-authored result summaries can be much wider than a resized chat pane. + The table itself owns horizontal scrolling, keeping the full transcript + readable and preventing a single artifact column from widening the shell. */ +.session-scroller [data-slot="session-turn-markdown"] table { + display: block; + width: max-content; + min-width: 100%; + max-width: 100%; + margin-block: 12px; + overflow-x: auto; + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); + border-radius: var(--radius-sm); + border-spacing: 0; + border-collapse: separate; + background: color-mix(in srgb, var(--color-surface-solid) 70%, transparent); + font-size: 12.5px; + line-height: 18px; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + scrollbar-width: thin; + scrollbar-color: var(--border-base) transparent; +} + +.session-scroller [data-slot="session-turn-markdown"] :is(th, td) { + min-width: max-content; + max-width: 32ch; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent); + text-align: left; + vertical-align: top; +} + +.session-scroller [data-slot="session-turn-markdown"] td:not(:last-child) { + white-space: nowrap; +} + +.session-scroller [data-slot="session-turn-markdown"] :is(th, td):last-child { + min-width: min(240px, 58cqi); + overflow-wrap: anywhere; + white-space: normal; +} + +.session-scroller [data-slot="session-turn-markdown"] th { + background: color-mix(in srgb, var(--color-bg-subtle) 76%, transparent); + color: var(--color-text-muted, var(--text-weak)); + font-size: 11.5px; + font-weight: var(--font-weight-medium); + line-height: 16px; + white-space: nowrap; +} + +.session-scroller [data-slot="session-turn-markdown"] tr:last-child td { + border-bottom: 0; +} + +.session-scroller [data-slot="session-turn-collapsible-trigger-content"] { + margin: -3px -7px; + padding: 3px 7px; + border: 0; + border-radius: var(--radius-xs); + color: var(--color-text-muted, var(--text-weak)); + font-size: 12px; + font-weight: var(--font-weight-regular); +} + +.session-scroller [data-slot="session-turn-collapsible-content-inner"] { + gap: 5px; + margin-left: 4px; + padding: 0 0 0 10px; + border-left-color: var(--border-weak-base); +} + +.session-scroller [data-slot="session-turn-status-text"] { + color: inherit; + font-weight: var(--font-weight-regular); +} + +.session-scroller [data-component="text-part"] [data-slot="text-part-body"], +.session-scroller [data-component="reasoning-part"] [data-component="markdown"] { + margin-top: 6px; +} + +.session-scroller [data-component="collapsible"] { + border-radius: var(--radius-xs); + background: transparent; +} + +.session-scroller [data-component="collapsible"] > [data-slot="collapsible-trigger"] { + height: 32px; + padding: 4px 6px; +} + +.session-scroller [data-component="tool-trigger"], +.session-scroller [data-slot="basic-tool-tool-trigger-content"] { + gap: 7px; +} + +.session-scroller [data-slot="basic-tool-tool-title"], +.session-scroller [data-slot="basic-tool-tool-subtitle"], +.session-scroller [data-slot="basic-tool-tool-arg"] { + font-size: 12px; + line-height: 18px; +} + +.session-scroller [data-component="tool-output"] { + padding: 2px 8px 7px; + font-size: 12px; + line-height: 18px; +} + +.session-scroller [data-slot="session-turn-summary-section"] { + gap: 10px; +} + +.session-scroller [data-slot="session-turn-summary-title"] { + display: none; +} + +.session-scroller [data-slot="session-turn-summary-header"] { + gap: 0; +} + +.session-scroller [data-slot="session-turn-generated"] > header { + color: var(--color-text-muted, var(--text-weak)); + font-size: 11.5px; + font-weight: var(--font-weight-regular); + letter-spacing: normal; + text-transform: none; +} + +.session-scroller [data-slot="session-turn-generated-artifact"] { + transition: + border-color 150ms ease, + background-color 150ms ease; +} + +.session-scroller :is([data-slot="session-turn-artifact-action"], [data-slot="session-turn-accordion-more"]) { + min-height: 32px; +} + +.session-jump-latest { + min-height: 32px; + transition: + border-color 150ms ease, + background-color 150ms ease, + color 150ms ease; +} + +/* Streaming text may opt into a shared stagger animation. Within chat, keep + that state change fast and simultaneous so a long answer never animates for + multiple seconds. */ +.session-scroller [data-slot="session-turn-markdown"][data-fade="true"] > * { + animation-duration: 180ms; + animation-delay: 0ms !important; +} + +@container conversation (max-width: 640px) { + .session-prompt-dock { + padding: 8px 12px calc(12px + env(safe-area-inset-bottom)); + } + + .session-transcript { + width: 100%; + padding-top: 12px; + } + + .session-scroller [data-slot="session-turn-message-container"] { + padding-inline: 12px; + } + + .session-scroller [data-slot="session-turn-message-content"] { + max-width: 100%; + } + + .session-scroller [data-component="user-message"] [data-slot="user-message-text"] { + max-width: min(86%, 580px); + } + + .session-turn-divider { + height: 22px; + } +} + +@media (max-width: 640px) { + [data-component="conversation-center"] { + --workspace-composer-height: calc(116px + env(safe-area-inset-bottom)); + } + + .session-prompt-dock { + padding: 8px 12px calc(12px + env(safe-area-inset-bottom)); + } + + .session-transcript { + width: 100%; + padding-top: 12px; + } + + .session-scroller [data-slot="session-turn-message-container"] { + padding-inline: 12px; + } + + .session-scroller [data-slot="session-turn-message-content"] { + max-width: 100%; + } + + .session-scroller [data-component="user-message"] [data-slot="user-message-text"] { + max-width: min(86%, 580px); + } + + .session-turn-divider { + height: 22px; + } +} + +@media (pointer: coarse) { + .session-scroller :where(button, [role="button"]), + .session-jump-latest { + min-height: 44px; + } + + .session-scroller [data-component="icon-button"] { + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .session-scroller [data-slot="session-turn-markdown"][data-fade="true"] > * { + animation: none; + } + + .session-scroller [data-slot="session-turn-generated-artifact"], + .session-jump-latest { + transition: none; + } +} diff --git a/frontend/workspace/src/components/chat-surface.test.ts b/frontend/workspace/src/components/chat-surface.test.ts new file mode 100644 index 00000000..399bde07 --- /dev/null +++ b/frontend/workspace/src/components/chat-surface.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" + +const source = await Bun.file(new URL("./prompt-input.tsx", import.meta.url)).text() +const composerCss = await Bun.file(new URL("./prompt-input.css", import.meta.url)).text() +const chatCss = await Bun.file(new URL("./chat-surface.css", import.meta.url)).text() +const shellCss = await Bun.file(new URL("../styles/atlas.css", import.meta.url)).text() +const session = await Bun.file(new URL("../pages/session.tsx", import.meta.url)).text() + +describe("conversation surface", () => { + test("aligns the transcript content edge with the 740px composer", () => { + expect(source).not.toContain('import "./chat-surface.css"') + expect(session).toContain('import "../components/chat-surface.css"') + expect(chatCss).toContain("width: min(100%, 740px)") + expect(chatCss).toContain("width: min(100%, 772px)") + expect(chatCss).toContain('[data-slot="session-turn-message-container"]') + expect(chatCss).toContain("padding-inline: 16px") + expect(chatCss).toContain("padding-inline: 12px") + }) + + test("keeps user messages quiet, readable, and separate from their copy action", () => { + expect(chatCss).toContain("--user-message-surface:") + expect(chatCss).toContain("max-width: min(76%, 580px)") + expect(chatCss).toContain("--user-message-radius: var(--radius-lg)") + expect(chatCss).toContain("--user-message-tail-radius: var(--radius-xs)") + expect(chatCss).toContain("font-size: 14px") + expect(chatCss).toContain("font-weight: var(--font-weight-regular)") + expect(chatCss).toContain("box-shadow: none") + expect(chatCss).toContain("min-width: 32px") + expect(chatCss).toContain('[data-slot="user-message-copy-wrapper"] [data-component="icon-button"]') + expect(chatCss).toContain("min-width: 44px") + expect(chatCss).not.toContain("position: absolute") + expect(shellCss).not.toContain('.session-scroller [data-component="user-message"] [data-slot="user-message-text"]') + expect(shellCss).not.toContain("\n.session-transcript {") + }) + + test("uses regular transcript type and sentence-case generated-state labels", () => { + expect(chatCss).toContain('[data-slot="session-turn-markdown"]') + expect(chatCss).toContain("line-height: 1.58") + expect(chatCss).toContain('data-diffs="true"') + expect(chatCss).toContain("text-wrap: pretty") + expect(chatCss).toContain("text-wrap: balance") + expect(chatCss).toContain('[data-slot="session-turn-generated"] > header') + expect(chatCss).toContain("text-transform: none") + expect(chatCss).toContain("letter-spacing: normal") + }) + + test("keeps model-authored data tables compact and scrollable inside resized panes", () => { + expect(chatCss).toContain("container-name: conversation") + expect(chatCss).toContain("container-type: inline-size") + expect(chatCss).toContain("@container conversation (max-width: 640px)") + expect(chatCss).toContain('[data-slot="session-turn-markdown"] table') + expect(chatCss).toContain("overflow-x: auto") + expect(chatCss).toContain("font-size: 12.5px") + expect(chatCss).toContain("font-variant-numeric: tabular-nums") + expect(chatCss).toContain('font-feature-settings: "tnum" 1') + expect(chatCss).toContain("min-width: max-content") + expect(chatCss).toContain("td:not(:last-child)") + expect(chatCss).toContain("white-space: nowrap") + expect(chatCss).toContain("min-width: min(240px, 58cqi)") + expect(chatCss).toContain("max-width: 32ch") + }) + + test("measures the variable-height composer and reserves a separate reading gap", () => { + expect(session).toContain("promptDockObserver = new ResizeObserver(measurePromptDock)") + expect(session).toContain('style.setProperty("--workspace-composer-height"') + expect(session).toContain("ref={(element) => (promptDockElement = element)}") + expect(chatCss).toContain("--workspace-composer-clearance: var(--space-5)") + expect(chatCss).toContain("--workspace-composer-reserve: calc(") + expect(chatCss).toContain("env(safe-area-inset-bottom)") + }) + + test("uses fast hover feedback, accessible targets, and reduced-motion fallbacks", () => { + expect(chatCss).toContain("150ms ease") + expect(chatCss).toContain("animation-duration: 180ms") + expect(chatCss).toContain("@media (pointer: coarse)") + expect(chatCss).toContain(':where(button, [role="button"])') + expect(chatCss).toContain('[data-component="icon-button"]') + expect(chatCss).toContain("min-width: 44px") + expect(chatCss).toContain("min-height: 44px") + expect(chatCss).toContain("@media (prefers-reduced-motion: reduce)") + expect(session).toContain('class="session-jump-latest"') + }) +}) + +describe("composer and state behavior", () => { + test("uses one restrained composer surface and one text baseline", () => { + expect(composerCss).toMatch(/form\.workspace-composer\s*\{[^}]*min-height: 92px/s) + expect(composerCss).toContain("border-radius: var(--radius-xl)") + expect(composerCss).toContain("--composer-editor-font-size: var(--font-size-base)") + expect(composerCss).toContain("--composer-editor-font-weight: var(--font-weight-regular)") + expect(composerCss).toContain("--composer-editor-line-height: 20px") + expect(composerCss).toContain("max-height: 240px") + expect(composerCss).toContain("box-shadow: var(--atlas-shadow-xs)") + expect(composerCss).toContain("@container conversation (max-width: 540px)") + expect(composerCss).toContain("@container conversation (max-width: 390px)") + expect(composerCss).toContain("38cqi") + }) + + test("preserves attachments, model selection, send, and stop controls", () => { + expect(source).toContain('class="workspace-composer__attachments"') + expect(source).toContain("<ModelSettingsPopover") + expect(source).toContain('icon={working() ? "stop" : "arrow-up"}') + expect(source).toContain('data-composer-action={working() ? "stop" : prompt.dirty() ? "send" : "idle"}') + expect(source).toContain('aria-label="Model and send"') + expect(source).toContain('data-attachment-status="attached"') + expect(source).toContain("attachmentFormat") + expect(source).toContain("multiple") + }) + + test("sentence-cases the revert action at a consistent weight", () => { + expect(session).toContain('"font-weight": "var(--font-weight-regular)"') + expect(session).toContain("Restore\n") + expect(session).not.toContain("\n restore\n") + }) +}) diff --git a/frontend/workspace/src/components/dialog-create-project.css b/frontend/workspace/src/components/dialog-create-project.css new file mode 100644 index 00000000..02ab39b7 --- /dev/null +++ b/frontend/workspace/src/components/dialog-create-project.css @@ -0,0 +1,345 @@ +[data-component="dialog"] [data-slot="dialog-content"].project-create-dialog { + width: min(100%, 34rem); + min-height: 0; + max-height: min(calc(100dvh - 2rem), 42rem); + align-self: center; + background: var(--surface-raised-stronger-non-alpha); +} + +.project-create-dialog [data-slot="dialog-header"] { + padding: 1.35rem 1.5rem 0.55rem; +} + +.project-create-dialog [data-slot="dialog-title"] { + color: var(--text-strong); + font-size: 1.125rem; + font-weight: var(--font-weight-emphasis); + letter-spacing: -0.018em; + line-height: 1.3; +} + +.project-create-dialog [data-slot="dialog-description"] { + max-width: 58ch; + margin: 0 !important; + padding: 0 3.5rem 1.25rem 1.5rem; + color: var(--text-weak); + font-size: 0.8125rem; + line-height: 1.5; + text-wrap: pretty; +} + +.project-create-dialog [data-slot="dialog-body"] { + overflow: auto; +} + +.project-create { + display: flex; + min-height: 0; + flex-direction: column; +} + +.project-create__body { + display: flex; + flex-direction: column; + gap: 1.4rem; + padding: 0 1.5rem 1.5rem; +} + +.project-create__field, +.project-create__sources { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.project-create__label, +.project-create__section-heading h2 { + margin: 0; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: 0.8125rem; + font-weight: var(--font-weight-emphasis); + letter-spacing: -0.006em; + line-height: 1.4; +} + +.project-create__section-heading p { + margin: 0.2rem 0 0; + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: 0.75rem; + font-weight: var(--font-weight-regular); + line-height: 1.5; + text-wrap: pretty; +} + +.project-create__input-frame { + display: flex; + height: 2.75rem; + align-items: center; + gap: 0.65rem; + overflow: hidden; + padding: 0 0.8rem; + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + background: var(--surface-base); + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard); +} + +.project-create__input-frame:focus-within { + border-color: var(--focus-lit-ring); + background: var(--surface-raised-base); + box-shadow: var(--focus-lit); +} + +.project-create__input-frame:has(input[aria-invalid="true"]) { + border-color: color-mix(in srgb, var(--text-danger) 46%, var(--border-base)); +} + +.project-create__input-icon, +.project-create__source-row-icon, +.project-create__source-icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + color: var(--text-weak); +} + +.project-create__input { + all: unset; + box-sizing: border-box; + min-width: 0; + height: 100%; + flex: 1; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: 0.875rem; + font-weight: var(--font-weight-regular); + line-height: 1.4; +} + +.project-create__input::placeholder { + color: var(--text-weaker); + opacity: 1; +} + +.project-create__source-empty { + all: unset; + box-sizing: border-box; + display: grid; + min-height: 4.25rem; + cursor: pointer; + grid-template-columns: 2rem minmax(0, 1fr); + align-items: center; + gap: 0.7rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-base); + text-align: left; + transition: + border-color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.project-create__source-empty:hover:not(:disabled) { + border-color: var(--border-strong-base); + background: var(--surface-base-hover); +} + +.project-create__source-empty:active:not(:disabled) { + transform: scale(0.99); +} + +.project-create__source-empty:disabled, +.project-create__add-more:disabled, +.project-create__remove-source:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.project-create__source-empty-copy, +.project-create__source-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.project-create__source-empty-copy { + gap: 0.15rem; +} + +.project-create__source-empty-copy strong, +.project-create__source-copy strong { + overflow: hidden; + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: 0.8125rem; + font-weight: var(--font-weight-emphasis); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-create__source-empty-copy span, +.project-create__source-copy > span { + overflow: hidden; + color: var(--text-weaker); + font-family: var(--font-family-sans); + font-size: 0.75rem; + font-weight: var(--font-weight-regular); + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-create__source-list { + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + background: transparent; +} + +.project-create__source-row { + display: grid; + min-height: 3.25rem; + grid-template-columns: 1.5rem minmax(0, 1fr) 2rem; + align-items: center; + gap: 0.65rem; + padding: 0.45rem 0.7rem; +} + +.project-create__source-row + .project-create__source-row { + border-top: 1px solid var(--border-base); +} + +.project-create__source-copy { + gap: 0.1rem; +} + +.project-create__remove-source { + all: unset; + box-sizing: border-box; + display: inline-flex; + width: 2rem; + height: 2rem; + cursor: pointer; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--text-weaker); + transition: + background var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.project-create__remove-source:hover { + background: var(--surface-base-hover); + color: var(--text-strong); +} + +.project-create__remove-source:active { + transform: scale(0.96); +} + +.project-create__add-more { + all: unset; + box-sizing: border-box; + display: inline-flex; + width: 100%; + min-height: 2.5rem; + cursor: pointer; + align-items: center; + justify-content: center; + gap: 0.4rem; + border-top: 1px solid var(--border-base); + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + transition: + background var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} + +.project-create__add-more:hover:not(:disabled) { + background: var(--surface-base-hover); + color: var(--text-strong); +} + +.project-create__error { + margin: 0; + color: var(--text-danger); + font-family: var(--font-family-sans); + font-size: 0.75rem; + line-height: 1.45; +} + +.project-create__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.875rem 1.5rem 1rem; + border-top: 1px solid var(--border-base); +} + +.project-create__footer [data-component="button"] { + min-width: 5.25rem; + padding-inline: 0.85rem; +} + +@media (max-width: 36rem) { + [data-component="dialog"] [data-slot="dialog-content"].project-create-dialog { + width: min(100%, calc(100vw - 1rem)); + max-height: min(calc(100dvh - 1rem), 42rem); + } + + .project-create-dialog [data-slot="dialog-header"] { + padding-inline: 1.2rem; + } + + .project-create-dialog [data-slot="dialog-description"] { + padding-right: 3.2rem; + padding-left: 1.2rem; + } + + .project-create__body { + padding-right: 1.2rem; + padding-left: 1.2rem; + } + + .project-create__footer { + padding-right: 1.2rem; + padding-left: 1.2rem; + } +} + +@media (pointer: coarse) { + .project-create__source-empty, + .project-create__remove-source, + .project-create__add-more, + .project-create__footer [data-component="button"] { + min-height: 2.75rem; + } + + .project-create__remove-source { + min-width: 2.75rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .project-create__input-frame, + .project-create__source-empty, + .project-create__remove-source, + .project-create__add-more { + transition: none; + } +} diff --git a/frontend/workspace/src/components/dialog-create-project.test.tsx b/frontend/workspace/src/components/dialog-create-project.test.tsx index 169aa29a..689e55f3 100644 --- a/frontend/workspace/src/components/dialog-create-project.test.tsx +++ b/frontend/workspace/src/components/dialog-create-project.test.tsx @@ -5,6 +5,7 @@ import { createServer } from "vite" import solid from "vite-plugin-solid" import type { ProjectCreateInput } from "./dialog-create-project" +const style = fileURLToPath(new URL("./dialog-create-project.css", import.meta.url)) const cleanups: Array<() => void> = [] const server = await createServer({ root: fileURLToPath(new URL("../..", import.meta.url)), @@ -79,6 +80,7 @@ describe("DialogCreateProject", () => { ) host.querySelector<HTMLButtonElement>("button")?.click() + expect(document.body.textContent).toContain("optionally connect folders") const input = document.body.querySelector<HTMLInputElement>('input[name="name"]') if (input) { input.value = "Cell atlas" @@ -138,7 +140,7 @@ describe("DialogCreateProject", () => { input.dispatchEvent(new InputEvent("input", { bubbles: true })) } expect(document.body.textContent).toContain("kras-speedrun") - expect(document.body.textContent).toContain("Read & write · this project") + expect(document.body.textContent).toContain("/Users/aayam/kras-speedrun") const create = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find( (button) => button.textContent === "Create project", ) @@ -152,4 +154,15 @@ describe("DialogCreateProject", () => { }, ]) }) + + test("uses one quiet responsive surface hierarchy", async () => { + const css = await Bun.file(style).text() + + expect(css).toContain(".project-create__source-empty") + expect(css).toContain("background: transparent") + expect(css).toContain("font-size: 0.875rem") + expect(css).toContain("@media (pointer: coarse)") + expect(css).toContain("@media (prefers-reduced-motion: reduce)") + expect(css).not.toContain("text-transform") + }) }) diff --git a/frontend/workspace/src/components/dialog-create-project.tsx b/frontend/workspace/src/components/dialog-create-project.tsx index 773be786..e2b40caa 100644 --- a/frontend/workspace/src/components/dialog-create-project.tsx +++ b/frontend/workspace/src/components/dialog-create-project.tsx @@ -1,9 +1,10 @@ import { Button } from "@synsci/ui/button" import { useDialog } from "@synsci/ui/context/dialog" import { Dialog } from "@synsci/ui/dialog" -import { IconFolder, IconPlus, IconX } from "@/atlas/shared/Icon" +import { IconFolder, IconFolderAdd, IconPlus, IconX } from "@/atlas/shared/Icon" import { For, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" +import "./dialog-create-project.css" export interface ProjectCreateInput { name: string @@ -48,16 +49,19 @@ export function DialogCreateProject(props: { } return ( - <Dialog title="Create project" class="project-create-dialog" fit transition> - <form class="flex flex-col" onSubmit={submit}> - <div class="flex flex-col gap-5 px-6 pb-6"> - <label class="flex flex-col gap-2"> - <span class="text-13-medium text-text-strong">Project name</span> - <span - data-focus-frame - class="flex h-11 items-center overflow-hidden rounded-[8px] border border-border-weak-base bg-surface-base transition focus-within:border-[var(--focus-lit-ring)] focus-within:shadow-[var(--focus-lit)]" - > - <span class="flex h-full w-11 flex-none items-center justify-center border-r border-border-weak-base text-text-weak"> + <Dialog + title="Create project" + description="Name the workspace and optionally connect folders that belong to this research." + class="project-create-dialog" + fit + transition + > + <form class="project-create" onSubmit={submit}> + <div class="project-create__body"> + <label class="project-create__field"> + <span class="project-create__label">Project name</span> + <span data-focus-frame class="project-create__input-frame"> + <span class="project-create__input-icon" aria-hidden="true"> <IconFolder size={16} strokeWidth={1.5} /> </span> <input @@ -70,7 +74,8 @@ export function DialogCreateProject(props: { autocomplete="off" placeholder="Project name" aria-invalid={state.error ? "true" : undefined} - class="h-full min-w-0 flex-1 border-0 bg-transparent px-3 text-14-regular text-text-strong outline-none placeholder:text-text-weaker" + aria-describedby={state.error ? "project-create-error" : undefined} + class="project-create__input" onInput={(event) => { const name = event.currentTarget.value setState({ name, error: "" }) @@ -80,14 +85,10 @@ export function DialogCreateProject(props: { </span> </label> - <section class="flex flex-col gap-2" aria-labelledby="source-folders-heading"> - <div> - <h2 id="source-folders-heading" class="m-0 text-13-medium text-text-strong"> - Source folders - </h2> - <p class="m-0 mt-0.5 text-12-regular text-text-weak"> - Available across this project with read and write access. - </p> + <section class="project-create__sources" aria-labelledby="source-folders-heading"> + <div class="project-create__section-heading"> + <h2 id="source-folders-heading">Source folders</h2> + <p>Optional. OpenScience can read and edit connected folders from this project.</p> </div> <Show @@ -95,38 +96,36 @@ export function DialogCreateProject(props: { fallback={ <button type="button" - class="flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-[8px] border border-border-weak-base bg-surface-raised-base/60 px-5 text-center text-text-base transition-colors hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus" + class="project-create__source-empty" disabled={state.busy} onClick={props.onChooseSources} > - <span class="flex h-9 w-9 items-center justify-center rounded-[7px] bg-surface-base text-text-weak"> - <IconPlus size={15} strokeWidth={1.7} /> + <span class="project-create__source-icon" aria-hidden="true"> + <IconFolderAdd size={17} strokeWidth={1.55} /> + </span> + <span class="project-create__source-empty-copy"> + <strong>Add source folders</strong> + <span>Connect up to 10 folders to this workspace</span> </span> - <span class="text-14-medium text-text-strong">Add source folders</span> - <span class="text-12-regular text-text-weak">Choose folders OpenScience can read and edit</span> </button> } > - <div class="overflow-hidden rounded-[8px] border border-border-weak-base bg-surface-base"> + <div class="project-create__source-list"> <For each={props.sources ?? []}> {(path) => ( - <div class="flex min-h-12 items-center gap-3 border-b border-border-weak-base px-3 last:border-b-0"> - <span class="flex h-8 w-8 flex-none items-center justify-center rounded-[6px] bg-surface-raised-base text-text-weak"> + <div class="project-create__source-row"> + <span class="project-create__source-row-icon" aria-hidden="true"> <IconFolder size={15} strokeWidth={1.5} /> </span> - <span class="min-w-0 flex-1"> - <strong class="block truncate text-13-medium text-text-strong"> - {path.split("/").filter(Boolean).at(-1) ?? path} - </strong> - <span class="block truncate text-11-regular text-text-weaker" title={path}> - Read & write · this project - </span> + <span class="project-create__source-copy"> + <strong>{path.split("/").filter(Boolean).at(-1) ?? path}</strong> + <span title={path}>{path}</span> </span> <button type="button" aria-label={`Remove source folder ${path}`} title="Remove source folder" - class="flex h-8 w-8 flex-none items-center justify-center rounded-[6px] text-text-weaker transition-colors hover:bg-surface-raised-base hover:text-text-strong" + class="project-create__remove-source" onClick={() => props.onRemoveSource?.(path)} > <IconX size={14} strokeWidth={1.6} /> @@ -136,7 +135,7 @@ export function DialogCreateProject(props: { </For> <button type="button" - class="flex min-h-10 w-full items-center justify-center gap-2 border-t border-border-weak-base bg-transparent text-12-medium text-text-base transition-colors hover:bg-surface-raised-base" + class="project-create__add-more" disabled={state.busy || (props.sources ?? []).length >= 10} onClick={props.onChooseSources} > @@ -147,18 +146,18 @@ export function DialogCreateProject(props: { </Show> <Show when={state.error}> - <p role="alert" class="m-0 text-12-regular text-text-danger"> + <p id="project-create-error" role="alert" class="project-create__error"> {state.error} </p> </Show> </section> </div> - <div class="flex items-center justify-end gap-2 border-t border-border-weak-base px-6 py-4"> - <Button type="button" variant="ghost" disabled={state.busy} onClick={() => dialog.close()}> + <div class="project-create__footer"> + <Button type="button" size="large" variant="ghost" disabled={state.busy} onClick={() => dialog.close()}> Cancel </Button> - <Button type="submit" variant="primary" disabled={state.busy || !state.name.trim()}> + <Button type="submit" size="large" variant="primary" disabled={state.busy || !state.name.trim()}> {state.busy ? "Creating…" : "Create project"} </Button> </div> diff --git a/frontend/workspace/src/components/dialog-select-model.css b/frontend/workspace/src/components/dialog-select-model.css index 400c4d4c..462e35f5 100644 --- a/frontend/workspace/src/components/dialog-select-model.css +++ b/frontend/workspace/src/components/dialog-select-model.css @@ -20,7 +20,7 @@ min-height: 24px; padding: 2px 6px; border: 1px solid var(--border-weaker-base); - border-radius: 7px; + border-radius: var(--radius-xs); color: var(--text-weak); font-size: 10px; line-height: 14px; @@ -37,27 +37,27 @@ outline: none; } -.model-picker-sheet__manage { - display: flex !important; +.model-picker-sheet [data-component="button"].model-picker-sheet__manage { + display: flex; width: calc(100% - 16px); - min-height: 52px !important; + min-height: 52px; flex: 0 0 52px; align-items: center; - justify-content: flex-start !important; - gap: 10px !important; - margin: 0 8px 8px !important; - padding: 7px 10px !important; - border: 1px solid var(--border-weaker-base) !important; - border-radius: 9px !important; - background: var(--surface-raised-base) !important; - color: var(--text-strong) !important; + justify-content: flex-start; + gap: 10px; + margin: 0 8px 8px; + padding: 7px 10px; + border: 1px solid var(--border-weaker-base); + border-radius: var(--radius-sm); + background: var(--surface-raised-base); + color: var(--text-strong); text-align: left; } -.model-picker-sheet__manage:hover, -.model-picker-sheet__manage:focus-visible { - border-color: var(--border-strong-base) !important; - background: var(--surface-raised-base-hover) !important; +.model-picker-sheet [data-component="button"].model-picker-sheet__manage:hover, +.model-picker-sheet [data-component="button"].model-picker-sheet__manage:focus-visible { + border-color: var(--border-strong-base); + background: var(--surface-raised-base-hover); outline: none; } @@ -72,7 +72,7 @@ .model-picker-sheet__manage-copy strong { color: var(--text-strong); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); line-height: 16px; } @@ -80,7 +80,7 @@ overflow: hidden; color: var(--text-weak); font-size: 10px; - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 14px; text-overflow: ellipsis; white-space: nowrap; @@ -90,7 +90,7 @@ flex: 0 0 auto; color: var(--text-weak); font-size: 18px; - font-weight: 300; + font-weight: var(--font-weight-regular); line-height: 20px; } @@ -104,7 +104,7 @@ height: 100%; min-height: 0; overflow: hidden; - border-radius: 12px; + border-radius: var(--radius-md); } .model-picker-sheet [data-slot="dialog-header"] { @@ -116,7 +116,7 @@ .model-picker-sheet [data-slot="dialog-title"] { font-size: 14px !important; - font-weight: 500 !important; + font-weight: var(--font-weight-medium) !important; line-height: 20px !important; letter-spacing: -0.01em !important; } @@ -145,7 +145,7 @@ .model-picker-sheet .model-picker-sheet__list [data-slot="list-search"] { min-height: 34px; padding: 6px 8px !important; - border-radius: 8px; + border-radius: var(--radius-xs); } .model-picker-sheet__list [data-slot="list-scroll"] { @@ -164,18 +164,18 @@ padding: 6px 8px 3px !important; background: var(--surface-raised-stronger-non-alpha); color: var(--text-weaker); - text-transform: uppercase; + letter-spacing: var(--letter-spacing-normal) !important; } .model-picker-sheet .model-picker-sheet__list [data-slot="list-item"] { min-height: 38px; padding: 4px 8px !important; - border-radius: 8px; + border-radius: var(--radius-xs); } .model-picker-sheet__list [data-slot="list-item"] .text-13-medium { font-size: 13px; - font-weight: 500; + font-weight: var(--font-weight-medium); line-height: 17px; } @@ -214,8 +214,8 @@ height: 100%; min-height: 0; overflow: hidden; - border-radius: 20px 20px 0 0; - box-shadow: 0 -18px 48px color-mix(in srgb, #000 20%, transparent); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + box-shadow: var(--atlas-shadow-float); } .model-picker-sheet [data-slot="dialog-header"] { @@ -227,7 +227,7 @@ .model-picker-sheet [data-slot="dialog-title"] { font-size: 16px !important; - font-weight: 600 !important; + font-weight: var(--font-weight-emphasis) !important; letter-spacing: -0.01em !important; line-height: 22px !important; } @@ -265,7 +265,7 @@ .model-picker-sheet__list [data-slot="list-search"] { min-height: 40px; padding-inline: 12px !important; - border-radius: 10px; + border-radius: var(--radius-sm); } .model-picker-sheet__list [data-slot="list-scroll"] { @@ -286,7 +286,7 @@ background: var(--surface-raised-stronger-non-alpha); color: var(--text-weaker); font-size: 11px !important; - text-transform: uppercase; + letter-spacing: var(--letter-spacing-normal) !important; } .model-picker-sheet__list [data-slot="list-item"] { @@ -303,7 +303,7 @@ .model-picker-sheet__list [data-slot="list-item"] .text-13-medium { font-size: 16px; - font-weight: 500; + font-weight: var(--font-weight-medium); line-height: 21px; letter-spacing: -0.01em; } diff --git a/frontend/workspace/src/components/dialog-select-model.tsx b/frontend/workspace/src/components/dialog-select-model.tsx index fc7b5c10..373d5928 100644 --- a/frontend/workspace/src/components/dialog-select-model.tsx +++ b/frontend/workspace/src/components/dialog-select-model.tsx @@ -65,7 +65,7 @@ const ModelList: Component<{ return ( <List - class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 [&_[data-slot=list-search-input]]:!text-[13px] [&_[data-slot=list-header]]:!text-[11px] [&_[data-slot=list-header]]:!font-medium [&_[data-slot=list-header]]:!tracking-[0.02em] [&_[data-slot=list-item]]:focus-visible:!outline [&_[data-slot=list-item]]:focus-visible:!outline-1 [&_[data-slot=list-item]]:focus-visible:!outline-border-strong ${props.class ?? ""}`} + class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 [&_[data-slot=list-search-input]]:!text-[13px] [&_[data-slot=list-header]]:!text-[11px] [&_[data-slot=list-header]]:!font-medium [&_[data-slot=list-item]]:focus-visible:!outline [&_[data-slot=list-item]]:focus-visible:!outline-1 [&_[data-slot=list-item]]:focus-visible:!outline-border-strong ${props.class ?? ""}`} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }} emptyMessage={language.t("dialog.model.empty")} key={(x) => `${x.provider.id}:${x.id}`} diff --git a/frontend/workspace/src/components/dialog-select-server.css b/frontend/workspace/src/components/dialog-select-server.css new file mode 100644 index 00000000..1cb15549 --- /dev/null +++ b/frontend/workspace/src/components/dialog-select-server.css @@ -0,0 +1,302 @@ +/* The server picker is intentionally scoped. It is a compact connection + utility, not a second settings screen, and must not reshape shared List, + Dialog, or Button primitives elsewhere in the app. */ +[data-component="dialog"]:has([data-slot="dialog-content"].server-dialog) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 560px); + height: auto; + max-height: min(calc(100vh - 24px), 560px); +} + +[data-component="dialog"]:has([data-slot="dialog-content"].server-dialog) [data-slot="dialog-content"] { + min-height: 0; + max-height: min(calc(100vh - 24px), 560px); + overflow: hidden; +} + +.server-dialog { + --server-radius-pill: 999px; + border: 1px solid var(--border-base); + border-radius: var(--radius-lg); + background: var(--surface-raised-stronger-non-alpha); + box-shadow: var(--atlas-shadow-float); + color: var(--text-base); +} + +.server-dialog [data-slot="dialog-header"] { + min-height: 52px; + padding: 14px 16px 6px 18px; +} + +.server-dialog [data-slot="dialog-title"] { + font-size: 17px; + font-weight: var(--font-weight-emphasis); + line-height: 24px; + letter-spacing: -0.015em; +} + +.server-dialog [data-slot="dialog-description"] { + margin: 0; + padding: 0 18px 14px; + color: var(--text-weak); + font-size: 13px; + line-height: 19px; +} + +.server-dialog__content { + min-height: 0; + display: flex; + flex-direction: column; +} + +.server-dialog__list { + min-height: 0; + gap: 10px; + padding: 0 18px; +} + +.server-dialog__list [data-slot="list-search-wrapper"] { + width: 100%; + margin: 0; +} + +.server-dialog__list [data-slot="list-search"] { + min-height: 38px; + padding: 7px 10px; + gap: 8px; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-sm); + background: var(--input-base); + transition: + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.server-dialog__list [data-slot="list-search"]:focus-within { + border-color: var(--focus-lit-ring); + box-shadow: var(--focus-lit); +} + +.server-dialog__list [data-slot="list-search-container"] { + max-height: none; +} + +.server-dialog__list [data-slot="list-search-input"] { + min-height: 22px; +} + +.server-dialog__list [data-slot="list-scroll"] { + max-height: 288px; + overflow-y: auto; +} + +.server-dialog__list [data-slot="list-group"]:last-child { + padding-bottom: 0; +} + +.server-dialog__list [data-slot="list-items"] { + overflow: hidden; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-sm); + background: var(--surface-raised-base); +} + +.server-dialog__list [data-slot="list-item"] { + min-height: 50px; + height: auto; + padding: 0 6px; + background: transparent; +} + +.server-dialog__list [data-slot="list-item"][data-active="true"] { + border-radius: 0; + background: var(--surface-raised-base-hover); +} + +.server-dialog__list [data-slot="list-item"]:focus-visible { + z-index: 1; + outline: 1px solid var(--border-strong-base); + outline-offset: -2px; +} + +.server-dialog__list [data-slot="list-item-divider"] { + left: 12px; + right: 12px; +} + +.server-dialog__list [data-slot="list-empty-state"] { + min-height: 76px; + padding: 18px; +} + +.server-dialog__row { + min-width: 0; + display: flex; + flex: 1; + align-items: center; + gap: 6px; +} + +.server-dialog__row > [data-component="tooltip-trigger"] { + min-width: 0; + flex: 1; +} + +.server-dialog__identity { + min-width: 0; + display: flex; + flex: 1; + align-items: center; + gap: 8px; + padding: 0 4px 0 6px; +} + +.server-dialog__status { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: var(--server-radius-pill); +} + +.server-dialog__name { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.server-dialog__badge { + min-height: 22px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + padding: 1px 7px; + border: 1px solid var(--border-weak-base); + border-radius: var(--server-radius-pill); + background: var(--surface-raised-base); + color: var(--text-weak); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 18px; + white-space: nowrap; +} + +.server-dialog__badge--current { + border-color: color-mix(in srgb, var(--icon-success-base) 22%, var(--border-weak-base)); + background: color-mix(in srgb, var(--icon-success-base) 8%, transparent); + color: var(--text-base); +} + +.server-dialog__actions { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.server-dialog__menu-button { + width: 32px; + height: 32px; + border-radius: var(--radius-xs); +} + +.server-dialog__editor { + min-width: 0; + width: 100%; +} + +.server-dialog__editor:not(:last-child) { + border-bottom: 1px solid var(--border-weak-base); +} + +.server-dialog__form { + min-width: 0; + width: 100%; + min-height: 56px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; +} + +.server-dialog__form-field { + min-width: 0; + flex: 1; +} + +.server-dialog__form-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; +} + +.server-dialog__footer { + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + margin-top: 12px; + padding: 10px 18px 14px; + border-top: 1px solid var(--border-weak-base); +} + +.server-dialog__add-button { + padding-inline: 10px; + border-radius: var(--radius-xs); +} + +@media (pointer: coarse) { + .server-dialog__list [data-slot="list-search"], + .server-dialog__list [data-slot="list-search-input"] { + min-height: 44px; + } + + .server-dialog__list [data-slot="list-item"] { + min-height: 52px; + } + + .server-dialog__menu-button { + min-width: 44px; + min-height: 44px; + } +} + +@media (max-width: 560px) { + [data-component="dialog"]:has([data-slot="dialog-content"].server-dialog) [data-slot="dialog-container"], + [data-component="dialog"]:has([data-slot="dialog-content"].server-dialog) [data-slot="dialog-content"] { + max-height: calc(100vh - 16px); + } + + .server-dialog__identity { + flex-wrap: wrap; + row-gap: 3px; + padding-block: 8px; + } + + .server-dialog__name { + flex-basis: calc(100% - 22px); + } + + .server-dialog__form { + flex-wrap: wrap; + } + + .server-dialog__form-field { + flex-basis: 100%; + } + + .server-dialog__form-actions { + margin-left: auto; + } +} + +@media (prefers-reduced-transparency: reduce) { + .server-dialog, + .server-dialog__list [data-slot="list-search"], + .server-dialog__list [data-slot="list-items"] { + background: var(--surface-raised-stronger-non-alpha); + } +} diff --git a/frontend/workspace/src/components/dialog-select-server.test.ts b/frontend/workspace/src/components/dialog-select-server.test.ts new file mode 100644 index 00000000..a7190829 --- /dev/null +++ b/frontend/workspace/src/components/dialog-select-server.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = () => readFileSync(fileURLToPath(new URL("./dialog-select-server.tsx", import.meta.url)), "utf8") +const styles = () => readFileSync(fileURLToPath(new URL("./dialog-select-server.css", import.meta.url)), "utf8") + +describe("server editor contract", () => { + test("persists add and edit changes only through explicit actions", () => { + const code = source() + + expect(code).not.toContain("onBlur=") + expect(code).toContain("onSave={() => void handleAdd(store.addServer.url)}") + expect(code).toContain("onSave={() => void handleEdit(item, store.editServer.value)}") + expect(code).toContain("onCancel={resetAdd}") + expect(code).toContain("onCancel={resetEdit}") + expect(code).toContain('type="submit"') + }) + + test("keeps Enter to save and Escape to cancel for keyboard users", () => { + const code = source() + + expect(code).toContain('event.key === "Escape"') + expect(code).toContain('event.key !== "Enter" || event.isComposing') + expect(code).toContain('label={language.t("dialog.server.add.url")}') + }) + + test("does not put the edit form inside the list item button", () => { + const code = source() + + expect(code).toContain("itemWrapper={(item, node) => (") + expect(code).toContain('data-slot="list-item-editor"') + }) + + test("uses a compact, fit-height connection surface instead of nested dark cards", () => { + const code = source() + const css = styles() + + expect(code).toContain('class="server-dialog"') + expect(code).toContain('description={language.t("dialog.server.description")}') + expect(code).toMatch(/class="server-dialog"[\s\S]*\bfit\b[\s\S]*\btransition\b/) + expect(css).toContain("width: min(calc(100vw - 24px), 560px)") + expect(css).toContain("height: auto") + expect(css).toMatch( + /\.server-dialog \[data-slot="dialog-title"\]\s*\{[^}]*font-weight: var\(--font-weight-emphasis\)/s, + ) + expect(css).toContain("box-shadow: var(--atlas-shadow-float)") + expect(css).toContain('.server-dialog__list [data-slot="list-items"]') + expect(css).toContain("background: var(--surface-raised-base)") + expect(code).not.toContain("bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md") + }) + + test("keeps status metadata sentence-cased and interaction targets accessible", () => { + const code = source() + const css = styles() + + expect(code).toContain('return value.trim().toLocaleLowerCase() === "local" ? "Local" : value') + expect(code).toContain('class="server-dialog__badge server-dialog__badge--current"') + expect(code).toContain('aria-label={language.t("common.moreOptions")}') + expect(code).toContain('icon="more-horizontal"') + expect(css).not.toContain("text-transform") + expect(css).toMatch(/@media \(pointer: coarse\)[\s\S]*min-height: 44px/) + expect(css).toMatch(/\.server-dialog__menu-button\s*\{[^}]*width: 32px;[^}]*height: 32px/s) + }) +}) diff --git a/frontend/workspace/src/components/dialog-select-server.tsx b/frontend/workspace/src/components/dialog-select-server.tsx index ea7e768b..0643d648 100644 --- a/frontend/workspace/src/components/dialog-select-server.tsx +++ b/frontend/workspace/src/components/dialog-select-server.tsx @@ -14,29 +14,43 @@ import { useLanguage } from "@/context/language" import { DropdownMenu } from "@synsci/ui/dropdown-menu" import { Tooltip } from "@synsci/ui/tooltip" import { showToast } from "@synsci/ui/toast" +import "./dialog-select-server.css" type ServerStatus = { healthy: boolean; version?: string } +function serverVersionLabel(value: string) { + return value.trim().toLocaleLowerCase() === "local" ? "Local" : value +} + interface AddRowProps { value: string + label: string placeholder: string adding: boolean error: string status: boolean | undefined + saveLabel: string + cancelLabel: string onChange: (value: string) => void onKeyDown: (event: KeyboardEvent) => void - onBlur: () => void + onSave: () => void + onCancel: () => void } interface EditRowProps { value: string + label: string placeholder: string busy: boolean error: string status: boolean | undefined + saveLabel: string + cancelLabel: string + canSave: boolean onChange: (value: string) => void onKeyDown: (event: KeyboardEvent) => void - onBlur: () => void + onSave: () => void + onCancel: () => void } async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { @@ -54,8 +68,15 @@ async function checkHealth(url: string, platform: ReturnType<typeof usePlatform> function AddRow(props: AddRowProps) { return ( - <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> - <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> + <form + class="server-dialog__form" + aria-label={props.label} + onSubmit={(event) => { + event.preventDefault() + props.onSave() + }} + > + <div class="server-dialog__form-field [&_[data-slot=input-wrapper]]:relative"> <div classList={{ "size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true, @@ -75,6 +96,7 @@ function AddRow(props: AddRowProps) { /> <TextField type="text" + label={props.label} hideLabel placeholder={props.placeholder} value={props.value} @@ -84,28 +106,44 @@ function AddRow(props: AddRowProps) { disabled={props.adding} onChange={props.onChange} onKeyDown={props.onKeyDown} - onBlur={props.onBlur} class="pl-7" /> </div> - </div> + <div class="server-dialog__form-actions"> + <Button type="button" size="normal" variant="ghost" disabled={props.adding} onClick={props.onCancel}> + {props.cancelLabel} + </Button> + <Button type="submit" size="normal" variant="primary" disabled={props.adding || !props.value.trim()}> + {props.saveLabel} + </Button> + </div> + </form> ) } function EditRow(props: EditRowProps) { return ( - <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}> + <form + class="server-dialog__form" + aria-label={props.label} + onClick={(event) => event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault() + props.onSave() + }} + > <div classList={{ - "size-1.5 rounded-full shrink-0": true, + "server-dialog__status mt-[13px]": true, "bg-icon-success-base": props.status === true, "bg-icon-critical-base": props.status === false, "bg-border-weak-base": props.status === undefined, }} /> - <div class="flex-1 min-w-0"> + <div class="server-dialog__form-field"> <TextField type="text" + label={props.label} hideLabel placeholder={props.placeholder} value={props.value} @@ -115,10 +153,17 @@ function EditRow(props: EditRowProps) { disabled={props.busy} onChange={props.onChange} onKeyDown={props.onKeyDown} - onBlur={props.onBlur} /> </div> - </div> + <div class="server-dialog__form-actions"> + <Button type="button" size="normal" variant="ghost" disabled={props.busy} onClick={props.onCancel}> + {props.cancelLabel} + </Button> + <Button type="submit" size="normal" variant="primary" disabled={props.busy || !props.canSave}> + {props.saveLabel} + </Button> + </div> + </form> ) } @@ -340,16 +385,13 @@ export function DialogSelectServer() { const handleAddKey = (event: KeyboardEvent) => { event.stopPropagation() - if (event.key !== "Enter" || event.isComposing) return - event.preventDefault() - handleAdd(store.addServer.url) - } - - const blurAdd = () => { - if (!store.addServer.url.trim()) { + if (event.key === "Escape") { + event.preventDefault() resetAdd() return } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() handleAdd(store.addServer.url) } @@ -370,8 +412,14 @@ export function DialogSelectServer() { } return ( - <Dialog title={language.t("dialog.server.title")}> - <div class="flex flex-col gap-2"> + <Dialog + title={language.t("dialog.server.title")} + description={language.t("dialog.server.description")} + class="server-dialog" + fit + transition + > + <div class="server-dialog__content"> <List search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} noInitialSelection @@ -386,21 +434,50 @@ export function DialogSelectServer() { resetAdd() } }} + itemWrapper={(item, node) => ( + <Show when={store.editServer.id === item} fallback={node}> + <div data-slot="list-item-editor" data-key={item} class="server-dialog__editor"> + <EditRow + value={store.editServer.value} + label={language.t("dialog.server.add.url")} + placeholder={language.t("dialog.server.add.placeholder")} + busy={store.editServer.busy} + error={store.editServer.error} + status={store.editServer.status} + saveLabel={language.t("dialog.server.action.save")} + cancelLabel={language.t("dialog.server.action.cancel")} + canSave={Boolean(store.editServer.value.trim()) && store.editServer.value !== item} + onChange={handleEditChange} + onKeyDown={(event) => handleEditKey(event, item)} + onSave={() => void handleEdit(item, store.editServer.value)} + onCancel={resetEdit} + /> + </div> + </Show> + )} divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" + class="server-dialog__list" add={ store.addServer.showForm ? { render: () => ( <AddRow value={store.addServer.url} + label={language.t("dialog.server.add.url")} placeholder={language.t("dialog.server.add.placeholder")} adding={store.addServer.adding} error={store.addServer.error} status={store.addServer.status} + saveLabel={ + store.addServer.adding + ? language.t("dialog.server.add.checking") + : language.t("dialog.server.add.button") + } + cancelLabel={language.t("dialog.server.action.cancel")} onChange={handleAddChange} onKeyDown={handleAddKey} - onBlur={blurAdd} + onSave={() => void handleAdd(store.addServer.url)} + onCancel={resetAdd} /> ), } @@ -438,151 +515,134 @@ export function DialogSelectServer() { } return ( - <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> - <Show - when={store.editServer.id !== i} - fallback={ - <EditRow - value={store.editServer.value} - placeholder={language.t("dialog.server.add.placeholder")} - busy={store.editServer.busy} - error={store.editServer.error} - status={store.editServer.status} - onChange={handleEditChange} - onKeyDown={(event) => handleEditKey(event, i)} - onBlur={() => handleEdit(i, store.editServer.value)} - /> - } - > - <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> + <div class="server-dialog__row group/item"> + <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> + <div class="server-dialog__identity" classList={{ "opacity-50": store.status[i]?.healthy === false }}> <div - class="flex items-center gap-3 px-4 min-w-0 flex-1" - classList={{ "opacity-50": store.status[i]?.healthy === false }} - > - <div - classList={{ - "size-1.5 rounded-full shrink-0": true, - "bg-icon-success-base": store.status[i]?.healthy === true, - "bg-icon-critical-base": store.status[i]?.healthy === false, - "bg-border-weak-base": store.status[i] === undefined, - }} - /> - <span ref={nameRef} class="truncate"> - {serverDisplayName(i)} - </span> - <Show when={store.status[i]?.version}> - <span ref={versionRef} class="text-text-weak text-14-regular truncate"> - {store.status[i]?.version} - </span> - </Show> - <Show when={defaultUrl() === i}> - <span class="text-12-regular text-text-weak bg-surface-base px-1.5 rounded-xs"> - {language.t("dialog.server.status.default")} + classList={{ + "server-dialog__status": true, + "bg-icon-success-base": store.status[i]?.healthy === true, + "bg-icon-critical-base": store.status[i]?.healthy === false, + "bg-border-weak-base": store.status[i] === undefined, + }} + /> + <span ref={nameRef} class="server-dialog__name"> + {serverDisplayName(i)} + </span> + <Show when={store.status[i]?.version}> + {(version) => ( + <span ref={versionRef} class="server-dialog__badge"> + {serverVersionLabel(version())} </span> - </Show> - </div> - </Tooltip> - </Show> - <Show when={store.editServer.id !== i}> - <div class="flex items-center justify-center gap-5 pl-4"> + )} + </Show> + <Show when={defaultUrl() === i}> + <span class="server-dialog__badge">{language.t("dialog.server.status.default")}</span> + </Show> <Show when={current() === i}> - <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> + <span class="server-dialog__badge server-dialog__badge--current"> + {language.t("dialog.server.current")} + </span> </Show> - - <DropdownMenu> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" - onClick={(e: MouseEvent) => e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content class="mt-1"> + </div> + </Tooltip> + <div class="server-dialog__actions"> + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="more-horizontal" + variant="ghost" + class="server-dialog__menu-button" + aria-label={language.t("common.moreOptions")} + onClick={(e: MouseEvent) => e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content class="mt-1"> + <DropdownMenu.Item + onSelect={() => { + resetAdd() + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) + }} + > + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <Show when={canDefault() && defaultUrl() !== i}> <DropdownMenu.Item - onSelect={() => { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) + onSelect={async () => { + try { + await platform.setDefaultServerUrl?.(i) + defaultUrlActions.mutate(i) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } }} > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - <Show when={canDefault() && defaultUrl() !== i}> - <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - }} - > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.default")} - </DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </Show> - <Show when={canDefault() && defaultUrl() === i}> - <DropdownMenu.Item - onSelect={async () => { - try { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - }} - > - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.defaultRemove")} - </DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </Show> - <DropdownMenu.Separator /> + </Show> + <Show when={canDefault() && defaultUrl() === i}> <DropdownMenu.Item - onSelect={() => handleRemove(i)} - class="text-text-on-critical-base hover:bg-surface-critical-weak" + onSelect={async () => { + try { + await platform.setDefaultServerUrl?.(null) + defaultUrlActions.mutate(null) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } + }} > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.defaultRemove")} + </DropdownMenu.ItemLabel> </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - </Show> + </Show> + <DropdownMenu.Separator /> + <DropdownMenu.Item + onSelect={() => handleRemove(i)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> </div> ) }} </List> - <div class="px-5 pb-5"> - <Button - variant="secondary" - icon="plus-small" - size="large" - onClick={() => { - setStore("addServer", { showForm: true, url: "", error: "" }) - scrollListToBottom() - }} - class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" - > - {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} - </Button> - </div> + <Show when={!store.addServer.showForm}> + <div class="server-dialog__footer"> + <Button + variant="ghost" + icon="plus-small" + size="normal" + onClick={() => { + resetEdit() + setStore("addServer", { showForm: true, url: "", error: "", status: undefined }) + scrollListToBottom() + }} + class="server-dialog__add-button" + > + {language.t("dialog.server.add.button")} + </Button> + </div> + </Show> </div> </Dialog> ) diff --git a/frontend/workspace/src/components/dialog-settings.test.ts b/frontend/workspace/src/components/dialog-settings.test.ts index 2226670c..6bfa72df 100644 --- a/frontend/workspace/src/components/dialog-settings.test.ts +++ b/frontend/workspace/src/components/dialog-settings.test.ts @@ -4,6 +4,32 @@ import { fileURLToPath } from "node:url" import { SETTINGS_PANELS } from "./settings/registry" const source = () => readFileSync(fileURLToPath(new URL("./dialog-settings.tsx", import.meta.url)), "utf8") +const appSource = () => readFileSync(fileURLToPath(new URL("../app.tsx", import.meta.url)), "utf8") +const registeredPanelSources = () => { + const registry = readFileSync(fileURLToPath(new URL("./settings/registry.ts", import.meta.url)), "utf8") + const modules = [...registry.matchAll(/import\("\.\/([^"\)]+)"\)/g)].map((match) => match[1]!) + expect(modules).toHaveLength(SETTINGS_PANELS.length) + + const files = [ + ...modules.map( + (module) => [`settings/${module}.tsx`, new URL(`./settings/${module}.tsx`, import.meta.url)] as const, + ), + ["settings/_shared.tsx", new URL("./settings/_shared.tsx", import.meta.url)] as const, + ["settings/ManagedInference.tsx", new URL("./settings/ManagedInference.tsx", import.meta.url)] as const, + ["settings/CodexConnection.tsx", new URL("./settings/CodexConnection.tsx", import.meta.url)] as const, + ["settings/ProviderKeys.tsx", new URL("./settings/ProviderKeys.tsx", import.meta.url)] as const, + ["settings/ProviderLogo.tsx", new URL("./settings/ProviderLogo.tsx", import.meta.url)] as const, + ["settings/CredentialServices.tsx", new URL("./settings/CredentialServices.tsx", import.meta.url)] as const, + ["settings-general.tsx", new URL("./settings-general.tsx", import.meta.url)] as const, + ["settings-permissions.tsx", new URL("./settings-permissions.tsx", import.meta.url)] as const, + ["link.tsx", new URL("./link.tsx", import.meta.url)] as const, + ["atlas/SkillsPage.tsx", new URL("../atlas/SkillsPage.tsx", import.meta.url)] as const, + ["atlas/skills-page.css", new URL("../atlas/skills-page.css", import.meta.url)] as const, + ["settings/skills.css", new URL("./settings/skills.css", import.meta.url)] as const, + ] + + return files.map(([name, url]) => ({ name, source: readFileSync(fileURLToPath(url), "utf8") })) +} test("settings use a compact responsive navigation frame", () => { const dialog = source() @@ -12,7 +38,23 @@ test("settings use a compact responsive navigation frame", () => { expect(dialog).toContain('class="settings-nav"') expect(dialog).toContain('class="settings-nav__sections') expect(dialog).toContain('class="settings-nav__item') - expect(dialog).toContain("@media (max-width: 720px)") + expect(dialog).toContain('aria-label="Earlier settings sections"') + expect(dialog).toContain('aria-label="Later settings sections"') + expect(dialog).toContain("navSections.scrollBy") + expect(dialog).toContain("disabled={!navCanScrollBack()}") + expect(dialog).toContain("disabled={!navCanScrollForward()}") + expect(dialog).toContain("scrollIntoView") + expect(dialog).toContain('class="settings-main__viewport"') + expect(dialog).toContain("@media (max-width: 980px)") + expect(dialog).toMatch(/\.settings-nav__sections\s*\{[^}]*min-height: 0;[^}]*flex: 1;[^}]*overflow-y: auto/s) + expect(dialog).toMatch( + /@media \(max-width: 980px\)[\s\S]*\.settings-nav__sections::-webkit-scrollbar\s*\{[^}]*display: block;[^}]*height: 3px/s, + ) + expect(dialog).toMatch( + /@media \(max-width: 980px\)[\s\S]*\.settings-nav\s*\{[^}]*grid-template-columns: 32px minmax\(0, 1fr\) 32px/s, + ) + expect(dialog).toMatch(/\.settings-main\s*\{[^}]*min-width: 0;[^}]*min-height: 0;[^}]*overflow: hidden/s) + expect(dialog).toMatch(/\.settings-main__viewport\s*\{[^}]*min-width: 0;[^}]*min-height: 0;[^}]*overflow: hidden/s) expect(dialog).not.toContain("w-[224px]") }) @@ -22,27 +64,209 @@ test("settings enforce one sentence-case typography system", () => { expect(dialog).toContain(".settings-dialog {") expect(dialog).toContain("font-family: var(--font-family-sans)") expect(dialog).toContain(".settings-section-label") - expect(dialog).toContain("text-transform: none") - expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*min-height: 34px/s) - expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*font-size: 12px/s) - expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*font-weight: 400/s) - expect(dialog).toMatch(/\.settings-nav__item\[data-active="true"\]\s*\{[^}]*font-weight: 500/s) - expect(dialog).toContain('size="small"') - expect(dialog).toContain('class="settings-main__title"') + expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*min-height: 32px/s) + expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*font-size: 13px/s) + expect(dialog).toMatch(/\.settings-nav__item\s*\{[^}]*font-weight: var\(--font-weight-regular\)/s) + expect(dialog).toMatch( + /\.settings-nav__item\[data-active="true"\]\s*\{[^}]*font-weight: var\(--font-weight-medium\)/s, + ) + expect(dialog).toContain('size="normal"') + expect(dialog).toContain('title="Settings"') + expect(dialog).toMatch(/\.settings-dialog > \[data-slot="dialog-header"\]\s*\{[^}]*clip-path: inset\(50%\)/s) + expect(dialog).not.toContain('class="settings-main__title"') expect(dialog).not.toContain("text-14-medium text-text-strong truncate pl-1") - expect(dialog).toContain(".text-16-medium, .text-14-medium, .text-13-medium, .text-12-medium") - expect(dialog).toContain("font-size: 12px !important") + expect(dialog).toMatch( + /\.settings-page-header h2\s*\{[^}]*display: block;[^}]*font-size: var\(--settings-type-title\)/s, + ) + expect(dialog).toMatch( + /\.settings-dialog \.settings-page-header h2\s*\{[^}]*font-weight: var\(--font-weight-medium\)/s, + ) + expect(dialog).toMatch(/\.settings-dialog \.text-16-medium\s*\{[^}]*font-size: var\(--settings-type-title\)/s) + expect(dialog).toMatch(/\.settings-dialog \.text-14-medium\s*\{[^}]*font-size: var\(--settings-type-heading\)/s) + expect(dialog).toMatch(/\.settings-dialog \.text-13-regular\s*\{[^}]*font-size: var\(--settings-type-body\)/s) + expect(dialog).toContain(".text-12-regular, .text-11-regular, .text-10-regular") + expect(dialog).not.toContain('.settings-dialog [style*="font-size"]') + expect(dialog).not.toContain("font-size: 12px !important") + expect(dialog).not.toContain("text-transform") expect(dialog).toContain("@media (prefers-reduced-transparency: reduce)") expect(dialog).toContain("@media (prefers-reduced-motion: reduce)") + expect(dialog).toContain("-webkit-font-smoothing: antialiased") + expect(dialog).toContain("font-synthesis: none") + expect(dialog).toContain("text-wrap: balance") + expect(dialog).toContain("text-wrap: pretty") + expect(dialog).toMatch(/\.settings-nav__label\s*\{[^}]*color: var\(--text-weak\)/s) +}) + +test("settings follow the userinterface interaction and spacing rules", () => { + const dialog = source() + + expect(dialog).toContain("--settings-space-1: 4px") + expect(dialog).toContain("--settings-space-7: 48px") + expect(dialog).toContain("background 140ms ease") + expect(dialog).toContain("transform 120ms ease") + expect(dialog).toContain("transition: transform 150ms ease") + expect(dialog).toMatch( + /\.settings-dialog :where\(button, input, select, textarea\):focus-visible\s*\{[^}]*transition-duration: 0ms/s, + ) + expect(dialog).toContain("@media (pointer: coarse)") + expect(dialog).toContain("min-height: 44px") + expect(dialog).not.toContain("transition: all") +}) + +test("settings navigation uses a quiet tonal selection without a bordered pill", () => { + const dialog = source() + + expect(dialog).toContain("--settings-selection: color-mix(in srgb, var(--text-strong) 8%, transparent)") + expect(dialog).toMatch( + /\.settings-nav__item\s*\{[^}]*min-height: 32px;[^}]*border: 0;[^}]*background: transparent;[^}]*box-shadow: none/s, + ) + expect(dialog).toMatch( + /\.settings-nav__item\[data-active="true"\]\s*\{[^}]*background: var\(--settings-selection\);[^}]*color: var\(--text-strong\);[^}]*box-shadow: none/s, + ) + expect(dialog).toMatch( + /\.settings-nav__item:focus-visible\s*\{[^}]*outline: 2px solid var\(--color-focus\);[^}]*outline-offset: 1px/s, + ) + expect(dialog).not.toContain("border-bottom-color: var(--text-base)") +}) + +test("settings acknowledge panel navigation before lazy work settles", () => { + const dialog = source() + + expect(dialog).not.toContain("preloadSettingsPanels") + expect(dialog).not.toContain("await preloadPanel(id)") + expect(dialog).toContain("Navigation acknowledges synchronously") + expect(dialog).toContain("setMounted((panels)") + expect(dialog).toContain("void preloadPanel(id)") + expect(dialog).toContain("requestIdle.call(window, preloadSkills, { timeout: 1_200 })") + expect(dialog).toContain("onPointerEnter={() => void preloadPanel(panel.id)") + expect(dialog).toContain("onFocus={() => void preloadPanel(panel.id)") +}) + +test("settings keep a restrained surface stack and aligned panel grid", () => { + const dialog = source() + + expect(dialog).toContain("width: min(calc(100vw - 32px), 1180px)") + expect(dialog).toContain("height: min(calc(100vh - 40px), 800px)") + expect(dialog).toMatch(/\.settings-nav\s*\{[^}]*width: 208px;[^}]*flex: 0 0 208px/s) + expect(dialog).toMatch( + /\.settings-page-header\s*\{[^}]*width: 100%;[^}]*padding: var\(--settings-space-5\) var\(--settings-space-6\) var\(--settings-space-3\)/s, + ) + expect(dialog).toMatch(/\.settings-page-header__inner\s*\{[^}]*width: min\(100%, 900px\)[^}]*margin-inline: auto/s) + expect(dialog).toMatch( + /\.settings-page-body\s*\{[^}]*width: 100%;[^}]*max-width: 900px[^}]*margin-inline: auto[^}]*padding: var\(--settings-space-3\) var\(--settings-space-6\) var\(--settings-space-7\)/s, + ) + expect(dialog).not.toContain("linear-gradient") + expect(dialog).toContain(".settings-card {") + expect(dialog).toContain(".settings-row {") + expect(dialog).not.toMatch(/bg-surface-base\/\d+/) + expect(dialog).not.toMatch(/rounded-\[\d+(?:\.\d+)?px\]/) +}) + +test("settings keep disabled controls visibly distinct from active actions", () => { + const dialog = source() + + expect(dialog).toMatch( + /\[data-component="button"\]:is\(:disabled, \[data-disabled\], \[aria-disabled="true"\]\)\s*\{[^}]*cursor: not-allowed;[^}]*opacity: 0\.48;[^}]*pointer-events: none/s, + ) + expect(dialog).toMatch( + /\[data-component="switch"\]:is\(\[data-disabled\], \[aria-disabled="true"\]\)\s*\{[^}]*cursor: not-allowed;[^}]*opacity: 0\.48/s, + ) +}) + +test("settings search and filter controls expose contextual accessible names", () => { + const shared = readFileSync(fileURLToPath(new URL("./settings/_shared.tsx", import.meta.url)), "utf8") + const specialists = readFileSync(fileURLToPath(new URL("./settings/Specialists.tsx", import.meta.url)), "utf8") + const skills = readFileSync(fileURLToPath(new URL("../atlas/SkillsPage.tsx", import.meta.url)), "utf8") + + expect(shared).toContain('aria-label={props.ariaLabel ?? props.placeholder ?? "Search"}') + expect(shared).toContain("aria-label={props.ariaLabel}") + expect(specialists).toContain('ariaLabel="Filter specialists by mode"') + expect(skills).toContain('ariaLabel="Filter skills by source"') + expect(skills).toContain('ariaLabel="Filter skills by category"') +}) + +test("settings reuse workspace surfaces and adaptive boundaries without a parallel palette", () => { + const dialog = source() + + expect(dialog).toContain("--settings-canvas: var(--background-base)") + expect(dialog).toContain("--settings-rail: var(--background-weak)") + expect(dialog).toContain("--settings-surface: var(--surface-raised-stronger-non-alpha)") + expect(dialog).toContain("--settings-border: var(--border-base)") + expect(dialog).toContain("--settings-accent: var(--border-selected)") + expect(dialog).not.toMatch(/#(?:007aff|0a84ff|0066d6|409cff)/i) + expect(dialog).toMatch(/\[data-slot="dialog-container"\]\s*\{[^}]*border: 1px solid var\(--settings-border\)/s) + expect(dialog).toMatch(/\.settings-nav\s*\{[^}]*border-right: 1px solid var\(--settings-border\)/s) + expect(dialog).toMatch(/\.settings-main__header\s*\{[^}]*border-bottom: 1px solid var\(--settings-border\)/s) + expect(dialog).toMatch(/\.settings-card\s*\{[^}]*border: 0;[^}]*box-shadow: none/s) + expect(dialog).toMatch(/\.settings-row\s*\{[^}]*border: 0;[^}]*border-radius: var\(--settings-radius-control\)/s) + expect(dialog).not.toContain("backdrop-filter: saturate") +}) + +test("settings use one concentric radius ladder and one structural boundary owner", () => { + const dialog = source() + + expect(dialog).toContain("--settings-radius-control: var(--radius-xs, 8px)") + expect(dialog).toContain("--settings-radius-card: var(--radius-md, 12px)") + expect(dialog).toContain("--settings-radius-modal: var(--radius-lg, 16px)") + expect(dialog).toContain("--settings-radius-pill: 999px") + expect(dialog).toContain("border-radius: calc(var(--settings-radius-control) - 2px)") + expect(dialog).toMatch(/\.settings-card\s*\{[^}]*border-radius: var\(--settings-radius-card\)/s) + expect(dialog).toMatch(/\.settings-row\s*\{[^}]*border-radius: var\(--settings-radius-control\)/s) + expect(dialog).not.toMatch(/\.settings-list\s*\{[^}]*border:/s) +}) + +test("settings expose a panel container without owning bespoke panel layouts", () => { + const dialog = source() + + expect(dialog).toMatch(/\.settings-main\s*\{[^}]*container-name: settings-main;[^}]*container-type: inline-size/s) + expect(dialog).toContain("@container settings-main (max-width: 600px)") + expect(dialog).not.toContain(".settings-skills") + expect(dialog).not.toContain(".skills-workspace") +}) + +test("every registered settings panel uses the shared semantic surface contract", () => { + const forbidden = [ + ["raw pixel radius utility", /rounded-\[\d+(?:\.\d+)?px\]/], + ["dark alpha surface utility", /bg-surface-base\/\d+/], + ["inline 4px radius", /(?:border-radius|["']border-radius["']|borderRadius)\s*:\s*["']?4px/], + ] as const + const violations = registeredPanelSources().flatMap(({ name, source }) => + forbidden.flatMap(([label, pattern]) => (pattern.test(source) ? [`${name}: ${label}`] : [])), + ) + + expect(violations).toEqual([]) }) test("settings dialog makes every registered capability navigable", () => { const dialog = source() const skills = SETTINGS_PANELS.find((panel) => panel.id === "skills") + const models = SETTINGS_PANELS.find((panel) => panel.id === "models") + const compute = SETTINGS_PANELS.find((panel) => panel.id === "compute") + const permissions = SETTINGS_PANELS.find((panel) => panel.id === "permissions") expect(skills?.title).toBe("Skills") + expect(models?.icon).toBe("models") + expect(compute?.icon).toBe("cpu") + expect(permissions?.icon).toBe("shield") expect(dialog).toContain("SETTINGS_PANELS.filter((p) => p.section === section.id)") - expect(dialog).toContain("onClick={() => navigate(panel.id)}") + expect(dialog).toContain("onClick={() => void navigate(panel.id)}") expect(dialog).toContain('aria-current={current().id === panel.id ? "page" : undefined}') - expect(dialog).toContain("<Dynamic component={current().component} />") + expect(dialog).toContain("<SettingsPanelStack") +}) + +test("settings preload by intent while retaining visited panel state", () => { + const dialog = source() + const app = appSource() + + expect(app).toContain("onMount(() => void preloadPanel(DEFAULT_PANEL)") + expect(dialog).not.toContain("void preloadPanel(DEFAULT_PANEL)") + expect(dialog).not.toContain("void preloadSettingsPanels()") + expect(dialog).not.toContain("await preloadPanel(id)") + expect(dialog).toContain("requestIdle.call(window, preloadSkills") + expect(dialog).toContain("void preloadPanel(id)") + expect(dialog).toContain("setMounted((panels)") + expect(dialog).toContain("SettingsPanelStack active={() => current().id} panels={mounted}") + expect(dialog).not.toContain("<Dynamic component={current().component}") + expect(dialog).not.toContain("Loading…") + expect(dialog).not.toContain("transition\n") }) diff --git a/frontend/workspace/src/components/dialog-settings.tsx b/frontend/workspace/src/components/dialog-settings.tsx index 7bbc0979..135ec74f 100644 --- a/frontend/workspace/src/components/dialog-settings.tsx +++ b/frontend/workspace/src/components/dialog-settings.tsx @@ -1,80 +1,200 @@ -import { Component, For, Suspense, createMemo, createSignal } from "solid-js" -import { Dynamic } from "solid-js/web" +import { Component, For, batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { Dialog } from "@synsci/ui/dialog" import { Icon } from "@synsci/ui/icon" import { IconButton } from "@synsci/ui/icon-button" import { useDialog } from "@synsci/ui/context/dialog" import { usePlatform } from "@/context/platform" -import { SETTINGS_PANELS, SETTINGS_SECTIONS, DEFAULT_PANEL, findPanel, type SettingsPanelId } from "./settings/registry" +import { + SETTINGS_PANELS, + SETTINGS_SECTIONS, + DEFAULT_PANEL, + findPanel, + preloadPanel, + type SettingsPanelId, +} from "./settings/registry" import { SettingsNavContext } from "./settings/nav" +import { SettingsPanelStack } from "./settings/panel-stack" -// Scoped to the settings dialog only. Reshapes shared primitives (Switch, -// Select) into the soft, rounded, iOS-style language settings uses — and widens -// the modal when expanded — without touching global component CSS or tokens. +// Scoped to the settings dialog only. Gives shared primitives and legacy +// panels one calm OpenScience hierarchy, grid, and surface stack without +// changing global component CSS or tokens. const SETTINGS_STYLES = ` +.settings-dialog, +[data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) { + --settings-space-1: 4px; + --settings-space-2: 8px; + --settings-space-3: 12px; + --settings-space-4: 16px; + --settings-space-5: 24px; + --settings-space-6: 32px; + --settings-space-7: 48px; + --settings-radius-control: var(--radius-xs, 8px); + --settings-radius-card: var(--radius-md, 12px); + --settings-radius-modal: var(--radius-lg, 16px); + --settings-radius-pill: 999px; + --settings-canvas: var(--background-base); + --settings-rail: var(--background-weak); + --settings-surface: var(--surface-raised-stronger-non-alpha); + --settings-surface-muted: var(--input-base); + --settings-surface-hover: var(--surface-base-hover); + --settings-surface-active: var(--surface-base-active); + --settings-selection: color-mix(in srgb, var(--text-strong) 8%, transparent); + --settings-border: var(--border-base); + --settings-border-strong: var(--border-strong-base); + --settings-accent: var(--border-selected); + --settings-accent-muted: color-mix(in srgb, var(--text-interactive-base) 10%, transparent); + --settings-accent-strong: var(--text-interactive-base); + /* Settings actions stay warm and tonal. Dark ink belongs to labels, not + large filled controls; compact active states carry the product color. */ + --settings-primary: var(--surface-interactive-base); + --settings-primary-hover: var(--surface-interactive-hover); + --settings-on-primary: var(--text-interactive-base); + --settings-toggle-active: var(--surface-brand-base); + --settings-shadow-modal: var(--atlas-shadow-md, var(--shadow-lg)); + --settings-shadow-card: var(--shadow-xs-border); + --settings-type-title: 18px; + --settings-type-heading: 13px; + --settings-type-body: 13px; + --settings-type-helper: 12px; + --settings-leading-title: 24px; + --settings-leading-body: 20px; + --settings-leading-helper: 18px; +} .settings-dialog { font-family: var(--font-family-sans); font-feature-settings: var(--font-family-sans--font-feature-settings, normal); - font-size: 12px; - font-weight: 400; - line-height: 18px; - background: color-mix(in srgb, var(--background-base) 88%, transparent); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); + min-width: 0; + min-height: 0; + background: var(--settings-canvas); color: var(--text-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-synthesis: none; } .settings-dialog h2, .settings-dialog h3, .settings-dialog h4 { font-family: inherit; letter-spacing: -0.01em; + text-wrap: balance; +} +.settings-dialog p { + text-wrap: pretty; } .settings-dialog button, .settings-dialog input, .settings-dialog select, .settings-dialog textarea { font-family: inherit; - text-transform: none; } .settings-dialog .atlas-section-label, .settings-section-label { color: var(--text-weak); font-family: inherit; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.01em; + font-size: 12px; + font-weight: var(--font-weight-medium); + letter-spacing: 0; line-height: 16px; - text-transform: none; } .settings-dialog [data-component="switch"] [data-slot="switch-control"] { - width: 38px; - height: 22px; - border-radius: 999px; + width: 28px; + height: 16px; + border: 1px solid var(--border-weak-base); + border-radius: var(--settings-radius-control); + background: var(--surface-base); padding: 0; } +.settings-dialog [data-component="switch"] { + min-height: 32px; +} .settings-dialog [data-component="switch"] [data-slot="switch-thumb"] { - width: 16px; - height: 16px; - border-radius: 999px; - border: none; - margin: 0 3px; - transform: translateX(0); + box-sizing: content-box; + width: 14px; + height: 14px; + border: 1px solid var(--border-base); + border-radius: var(--settings-radius-control); + background: var(--icon-invert-base); + box-shadow: var(--shadow-xs); + margin: 0; + transform: translateX(-1px); + transition: transform 150ms ease; } .settings-dialog [data-component="switch"][data-checked] [data-slot="switch-control"], .settings-dialog [data-component="switch"][data-checked]:hover:not([data-disabled],[data-readonly]) [data-slot="switch-control"] { - background-color: var(--color-text-interactive-base, var(--icon-strong-base)); - border-color: var(--color-text-interactive-base, var(--icon-strong-base)); + background-color: var(--settings-toggle-active); + border-color: var(--settings-toggle-active); } .settings-dialog [data-component="switch"][data-checked] [data-slot="switch-thumb"] { - transform: translateX(16px); + border: 0; + transform: translateX(12px); } .settings-dialog [data-slot="select-select-trigger"] { - border-radius: 9px; + min-height: 32px; + border-color: transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} +.settings-dialog [data-component="button"] { + border-radius: var(--settings-radius-control); + box-shadow: none; +} +.settings-dialog [data-component="button"][data-variant="secondary"] { + border: 1px solid transparent; + background: var(--settings-surface-muted); + color: var(--text-strong); +} +.settings-dialog [data-component="button"][data-variant="secondary"]:hover:not(:disabled), +.settings-dialog [data-component="button"][data-variant="secondary"]:focus:not(:disabled) { + border-color: transparent; + background: var(--settings-surface-hover); +} +.settings-dialog [data-component="button"][data-variant="primary"] { + border-color: var(--border-weak-base); + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: var(--shadow-xs-border); +} +.settings-dialog [data-component="button"][data-variant="primary"]:hover:not(:disabled), +.settings-dialog [data-component="button"][data-variant="primary"]:focus:not(:disabled) { + border-color: var(--border-hover); + background: var(--settings-primary-hover); +} +.settings-dialog [data-component="button"]:is(:disabled, [data-disabled], [aria-disabled="true"]) { + cursor: not-allowed; + opacity: 0.48; + pointer-events: none; +} +.settings-dialog [data-component="switch"]:is([data-disabled], [aria-disabled="true"]) { + cursor: not-allowed; + opacity: 0.48; +} +.settings-dialog [data-component="switch"]:is([data-disabled], [aria-disabled="true"]) + [data-slot="switch-control"] { + cursor: not-allowed; +} +.settings-dialog [data-component="button"][data-variant="ghost"]:hover:not(:disabled), +.settings-dialog [data-component="button"][data-variant="ghost"]:focus-visible:not(:disabled) { + background: var(--settings-surface-hover); +} +.settings-dialog > [data-slot="dialog-header"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; } [data-component="select-content"][data-trigger-style="settings"] { - border-radius: 10px; + border-radius: var(--settings-radius-card); padding: 5px; } [data-component="select-content"][data-trigger-style="settings"] [data-slot="select-select-item"] { - border-radius: 7px; + border-radius: var(--settings-radius-control); } /* ── Fixed modal frame ────────────────────────────────────────────────────── @@ -84,24 +204,33 @@ const SETTINGS_STYLES = ` so the box jumps size between tabs — the fix is to pin content to the fixed container height and let panels manage their own internal overflow. */ [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-container"] { - width: min(calc(100vw - 32px), 960px); - height: min(calc(100vh - 40px), 720px); + box-sizing: border-box; + width: min(calc(100vw - 32px), 1180px); + height: min(calc(100vh - 40px), 800px); overflow: hidden; - border: 1px solid color-mix(in srgb, var(--border-base) 82%, transparent); - border-radius: 18px; - background: color-mix(in srgb, var(--background-base) 90%, transparent); - box-shadow: - 0 1px 0 color-mix(in srgb, #fff 7%, transparent) inset, - 0 22px 70px color-mix(in srgb, #000 30%, transparent); - backdrop-filter: saturate(1.16) blur(28px); - -webkit-backdrop-filter: saturate(1.16) blur(28px); + border: 1px solid var(--settings-border); + border-radius: var(--settings-radius-modal); + background: var(--settings-canvas); + box-shadow: var(--settings-shadow-modal); + isolation: isolate; } [data-component="dialog"]:has([data-slot="dialog-content"].settings-expanded) [data-slot="dialog-container"] { - width: min(calc(100vw - 32px), 1200px); - height: min(calc(100vh - 40px), 840px); + width: min(calc(100vw - 32px), 1280px); + height: min(calc(100vh - 40px), 880px); } [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-content"] { + width: 100%; height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + border-radius: inherit; + background: transparent; + box-shadow: none; +} +[data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-body"] { + height: 100%; + min-width: 0; min-height: 0; overflow: hidden; } @@ -110,23 +239,43 @@ const SETTINGS_STYLES = ` display: flex; width: 100%; height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; } .settings-nav { - width: 206px; - flex: 0 0 206px; + width: 208px; + min-height: 0; + flex: 0 0 208px; display: flex; flex-direction: column; justify-content: space-between; - padding: 14px 10px; - border-right: 1px solid color-mix(in srgb, var(--border-weak-base) 82%, transparent); - background: color-mix(in srgb, var(--background-strong) 76%, transparent); + padding: var(--settings-space-4) var(--settings-space-3) var(--settings-space-3); + overflow: hidden; + border-right: 1px solid var(--settings-border); + background: var(--settings-rail); +} +.settings-nav__title { + flex: 0 0 auto; + padding: var(--settings-space-1) var(--settings-space-2) var(--settings-space-4); + color: var(--text-strong); + font-size: 15px; + font-weight: var(--font-weight-medium); + line-height: 22px; + letter-spacing: -0.012em; +} +.settings-nav__scroll-button { + display: none; } .settings-nav__sections { + min-height: 0; display: flex; + flex: 1; flex-direction: column; - gap: 20px; - padding-top: 4px; + gap: var(--settings-space-4); + padding-top: 2px; overflow-y: auto; + overscroll-behavior: contain; scrollbar-width: none; } .settings-nav__sections::-webkit-scrollbar { @@ -135,27 +284,29 @@ const SETTINGS_STYLES = ` .settings-nav__section { display: flex; flex-direction: column; - gap: 3px; + gap: var(--settings-space-1); } .settings-nav__label { - padding: 0 7px 6px; - color: var(--text-weaker); + padding: 0 var(--settings-space-2) var(--settings-space-1); + color: var(--text-weak); font-size: 11px; - font-weight: 500; + font-weight: var(--font-weight-regular); letter-spacing: 0; - text-transform: none; } .settings-nav__item { min-width: 0; - min-height: 34px; + min-height: 32px; display: grid; - grid-template-columns: 24px minmax(0, 1fr); + grid-template-columns: 20px minmax(0, 1fr); align-items: center; - gap: 6px; - padding: 4px 7px; - border-radius: 8px; - font-size: 12px; - font-weight: 400; + gap: var(--settings-space-2); + padding: 4px var(--settings-space-2); + border: 0; + border-radius: var(--settings-radius-control); + background: transparent; + box-shadow: none; + font-size: 13px; + font-weight: var(--font-weight-regular); line-height: 18px; color: var(--text-weak); text-align: left; @@ -165,27 +316,38 @@ const SETTINGS_STYLES = ` transform 120ms ease; } .settings-nav__item:hover { - background: var(--surface-raised-strong); + background: var(--settings-surface-hover); color: var(--text-strong); } .settings-nav__item[data-active="true"] { - background: color-mix(in srgb, var(--surface-raised-strong) 88%, transparent); + background: var(--settings-selection); color: var(--text-strong); - font-weight: 500; - box-shadow: 0 1px 0 color-mix(in srgb, #fff 5%, transparent) inset; + font-weight: var(--font-weight-medium); + box-shadow: none; +} +.settings-nav__item [data-component="icon"] { + color: var(--icon-weak-base); +} +.settings-nav__item:hover [data-component="icon"], +.settings-nav__item[data-active="true"] [data-component="icon"] { + color: var(--text-base); +} +.settings-nav__item[data-pending="true"] [data-component="icon"] { + opacity: 0.55; } .settings-nav__item:active { transform: scale(0.98); } .settings-nav__item:focus-visible { - outline: 1px solid var(--text-strong); - outline-offset: -2px; + outline: 2px solid var(--color-focus); + outline-offset: 1px; } .settings-nav__footer { display: flex; + flex: 0 0 auto; flex-direction: column; gap: 1px; - padding: 8px 9px 0; + padding: 12px 8px 0; color: var(--text-weak); } .settings-nav__footer > span { @@ -194,35 +356,127 @@ const SETTINGS_STYLES = ` white-space: nowrap; } .settings-main { + width: 100%; + height: 100%; min-width: 0; + min-height: 0; display: flex; flex: 1; flex-direction: column; - background: color-mix(in srgb, var(--background-base) 86%, transparent); + overflow: hidden; + background: var(--settings-canvas); + container-name: settings-main; + container-type: inline-size; } .settings-main__header { - min-height: 54px; - display: flex; + min-height: 48px; + display: grid; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); align-items: center; justify-content: space-between; gap: 8px; - padding: 0 14px; - border-bottom: 1px solid color-mix(in srgb, var(--border-weak-base) 82%, transparent); - background: color-mix(in srgb, var(--background-base) 72%, transparent); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); + padding: 0 var(--settings-space-4); + border-bottom: 1px solid var(--settings-border); + background: var(--settings-canvas); flex-shrink: 0; } -.settings-main__title { +.settings-main__header > :last-child { + justify-self: end; +} +.settings-main__context { min-width: 0; - overflow: hidden; - padding-left: 4px; - color: var(--text-strong); + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--text-weak); font-size: 12px; - font-weight: 500; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: var(--font-weight-regular); + line-height: 18px; +} +.settings-main__viewport { + position: relative; + width: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + overscroll-behavior: contain; + background: var(--settings-canvas); +} +.settings-main__viewport > :where(*) { + min-width: 0; +} +.settings-panel-slot { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +} +.settings-panel-slot[hidden] { + display: none; +} +.settings-panel-loading { + width: 100%; + height: 100%; + background: var(--settings-canvas); +} +.settings-panel-loading__header { + display: flex; + flex-direction: column; + gap: 9px; + padding: 28px 44px 22px; + border-bottom: 1px solid var(--settings-border); +} +.settings-panel-loading__body { + display: flex; + max-width: 900px; + flex-direction: column; + gap: 14px; + margin-inline: auto; + padding: 28px 44px 64px; +} +.settings-panel-loading__line, +.settings-panel-loading__rows span { + display: block; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} +.settings-panel-loading__line[data-size="title"] { + width: 132px; + height: 20px; +} +.settings-panel-loading__line[data-size="copy"] { + width: min(420px, 72%); + height: 13px; +} +.settings-panel-loading__line[data-size="label"] { + width: 84px; + height: 14px; +} +.settings-panel-loading__rows { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: var(--settings-shadow-card); +} +.settings-panel-loading__rows span { + height: 64px; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} +.settings-panel-loading__rows span + span { + border-top: 0; } .settings-page-header { @@ -230,39 +484,56 @@ const SETTINGS_STYLES = ` top: 0; z-index: 10; display: flex; - width: min(100%, 800px); + width: 100%; flex-direction: column; - gap: 10px; - padding: 22px 30px 15px; - background: linear-gradient(to bottom, color-mix(in srgb, var(--background-base) 93%, transparent) 80%, transparent); + gap: var(--settings-space-3); + padding: var(--settings-space-5) var(--settings-space-6) var(--settings-space-3); + border-bottom: 0; + background: var(--settings-canvas); } .settings-page-header__inner { display: flex; + width: min(100%, 900px); flex-direction: column; - gap: 4px; + gap: var(--settings-space-1); + margin-inline: auto; } .settings-page-header h2 { - display: none; + display: block; + margin: 0; + color: var(--text-strong); + font-size: var(--settings-type-title); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-title); + letter-spacing: -0.015em; } .settings-page-header p { - max-width: 660px; + max-width: 700px; color: var(--text-weak); - font-size: 12px; - font-weight: 400; - line-height: 18px; + font-size: var(--settings-type-body); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); } .settings-page-body { display: flex; - width: min(100%, 800px); + width: 100%; + max-width: 900px; + flex-direction: column; + gap: var(--settings-space-5); + margin-inline: auto; + padding: var(--settings-space-3) var(--settings-space-6) var(--settings-space-7); +} +.settings-section { + display: flex; + min-width: 0; flex-direction: column; - gap: 22px; - padding: 2px 30px 44px; + gap: var(--settings-space-3); } .settings-section-heading { display: flex; - align-items: flex-end; + align-items: flex-start; justify-content: space-between; - gap: 20px; + gap: var(--settings-space-4); } .settings-section-heading > div { min-width: 0; @@ -270,25 +541,32 @@ const SETTINGS_STYLES = ` .settings-section-heading h3 { margin: 0; color: var(--text-strong); - font-size: 13px; - font-weight: 500; + font-size: var(--settings-type-heading); + font-weight: var(--font-weight-medium); line-height: 1.35; } .settings-section-heading p { - margin: 3px 0 0; + max-width: 640px; + margin: var(--settings-space-1) 0 0; color: var(--text-weak); - font-size: 12px; - line-height: 1.45; + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); } .settings-section-heading > span { flex: 0 0 auto; - color: var(--text-weaker); - font-size: 11px; + color: var(--text-weak); + font-size: 12px; +} +.settings-section-heading--compact { + min-height: 20px; + align-items: center; + gap: 8px; + padding-inline: 2px; } .settings-error { padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--text-danger) 35%, var(--border-base)); - border-radius: 9px; + border-radius: var(--settings-radius-control); color: var(--text-danger); font-size: 12px; line-height: 1.5; @@ -298,21 +576,16 @@ const SETTINGS_STYLES = ` flex-direction: column; gap: 12px; } -.settings-list { - overflow: hidden; - border: 1px solid var(--border-weak-base); - border-radius: 11px; - background: color-mix(in srgb, var(--surface-raised-base) 70%, transparent); -} .settings-list-item + .settings-list-item { - border-top: 1px solid var(--border-weak-base); + border-top: 0; } .settings-list-row { - min-height: 54px; + min-height: 56px; display: flex; align-items: center; - gap: 11px; - padding: 8px 11px 8px 13px; + gap: var(--settings-space-3); + padding: 10px var(--settings-space-3); + border-radius: var(--settings-radius-control); } .settings-list-copy { min-width: 0; @@ -323,17 +596,15 @@ const SETTINGS_STYLES = ` } .settings-list-copy strong { color: var(--text-strong); - font-size: 12px; - font-weight: 500; - line-height: 18px; + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); } .settings-list-copy span { - overflow: hidden; color: var(--text-weak); - font-size: 11px; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 12px; + line-height: var(--settings-leading-helper); + text-wrap: pretty; } .settings-list-actions, .credential-form-actions { @@ -343,14 +614,11 @@ const SETTINGS_STYLES = ` } .credential-form { display: grid; - gap: 12px; - padding: 2px 14px 16px 57px; + gap: var(--settings-space-3); + padding: 4px var(--settings-space-3) var(--settings-space-4) 56px; } .credential-form--custom { - padding: 16px; - border: 1px solid var(--border-weak-base); - border-radius: 12px; - background: var(--surface-raised-base); + padding: 20px; } .credential-form-grid { display: grid; @@ -365,17 +633,17 @@ const SETTINGS_STYLES = ` } .credential-form label > span { color: var(--text-weak); - font-size: 11px; + font-size: 12px; } .credential-form input, .credential-form textarea { width: 100%; - min-height: 36px; - padding: 8px 10px; - border: 1px solid var(--border-weak-base); - border-radius: 9px; + min-height: 32px; + padding: 5px 9px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); outline: none; - background: var(--surface-raised-strong); + background: var(--settings-surface-muted); color: var(--text-strong); font-size: 13px; } @@ -385,80 +653,569 @@ const SETTINGS_STYLES = ` } .credential-form input:focus, .credential-form textarea:focus { - border-color: var(--border-strong-base); + border-color: var(--focus-lit-ring); + box-shadow: var(--focus-lit-halo); } .credential-form > p { margin: -2px 0 0; - color: var(--text-weaker); - font-size: 11px; + color: var(--text-weak); + font-size: 12px; } .settings-add-row { - min-height: 38px; + min-height: 32px; align-self: flex-start; padding: 0 11px; - border-radius: 9px; + border-radius: var(--settings-radius-control); color: var(--text-weak); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .settings-add-row:hover { - background: var(--surface-raised-base); + background: var(--settings-surface-hover); color: var(--text-strong); } -/* The panel implementations predate this shell and use several nominal type - utilities. Normalize them here so every tab carries the same compact rhythm - as the navigation rail, including panels that are lazy-loaded later. */ -.settings-dialog :where(.text-16-medium, .text-14-medium, .text-13-medium, .text-12-medium) { - font-family: inherit !important; - font-size: 12px !important; - font-weight: 500 !important; - line-height: 18px !important; - letter-spacing: 0 !important; -} -.settings-dialog :where(.text-14-regular, .text-13-regular, .text-12-regular) { - font-family: inherit !important; - font-size: 12px !important; - font-weight: 400 !important; - line-height: 18px !important; - letter-spacing: 0 !important; -} -.settings-dialog :where(.text-11-medium, .text-11-regular) { - font-family: inherit !important; - font-size: 11px !important; - font-weight: 400 !important; - line-height: 16px !important; - letter-spacing: 0 !important; -} -.settings-dialog .text-11-medium { - font-weight: 500 !important; -} -.settings-dialog :where(.text-10-medium, .text-10-regular) { - font-family: inherit !important; - font-size: 10px !important; - font-weight: 400 !important; - line-height: 14px !important; - letter-spacing: 0 !important; -} -.settings-dialog .text-10-medium { - font-weight: 500 !important; -} -.settings-dialog [style*="font-size"] { - font-size: 12px !important; - line-height: 18px !important; -} -.settings-dialog :where(h2, h3, h4) { - font-size: 12px !important; - font-weight: 500 !important; - line-height: 18px !important; -} -.settings-dialog :where(button, input, select, textarea) { +/* Shared panel primitives follow the same 24px page grid and restrained + surface stack as the shell. This prevents toolbars and list cards from + turning into unrelated dark islands in the dark theme. */ +.settings-card { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: none; +} +.settings-form-card { + display: flex; + flex-direction: column; + gap: 18px; + padding: var(--settings-space-5); +} +.settings-row { + min-height: 56px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--settings-space-3); + padding: 10px var(--settings-space-3); + border: 0; + border-radius: var(--settings-radius-control); +} +.settings-row[data-interactive="true"] { + cursor: pointer; +} +.settings-row[data-interactive="true"]:hover { + background: var(--settings-surface-hover); +} +.settings-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 16px; + text-align: center; +} +.settings-empty-state__icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface-muted); + color: var(--icon-weak-base); +} +.settings-alert { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border: 0; + border-radius: var(--settings-radius-control); + color: var(--text-weak); + background: var(--settings-surface-muted); +} +.settings-alert[data-tone="critical"] { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 6%, transparent); +} +.settings-alert[data-tone="warning"] { + color: var(--text-warning-base); + background: var(--surface-warning-weak); +} +.settings-alert[data-stacked="true"] { + align-items: stretch; + flex-direction: column; + justify-content: flex-start; +} +.settings-inline-action { + min-height: 32px; + padding: 4px 9px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + color: var(--text-weak); + background: transparent; font-size: 12px; - line-height: 18px; + font-weight: var(--font-weight-medium); +} +.settings-inline-action:hover { + color: var(--text-strong); + background: var(--settings-surface-hover); +} +.settings-inline-action[data-quiet="true"] { + border-color: transparent; +} +.settings-choice-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.settings-choice { + min-height: 88px; + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--settings-space-3); + border: 1px solid transparent; + border-radius: var(--settings-radius-card); + color: var(--text-weak); + background: var(--settings-surface-muted); + text-align: left; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} +.settings-choice:hover:not(:disabled) { + background: var(--settings-surface-hover); +} +.settings-choice[aria-pressed="true"] { + border-color: var(--settings-border-strong); + color: var(--text-strong); + background: var(--settings-surface); +} +.settings-choice:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.settings-filter-pill { + min-height: 32px; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: var(--settings-radius-pill); + color: var(--text-weak); + background: transparent; + font-size: 11px; + font-weight: var(--font-weight-medium); +} +.settings-filter-pill:hover, +.settings-filter-pill[aria-pressed="true"] { + color: var(--text-strong); + background: var(--settings-surface-hover); +} +.settings-filter-pill[aria-pressed="true"] { + border-color: transparent; +} +.settings-list-header { + min-height: 34px; + display: flex; + align-items: center; + padding: 0 18px; + border-bottom: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} +.settings-model-row { + flex-wrap: nowrap; + justify-content: space-between; +} +.settings-icon-action { + width: 32px; + height: 32px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: var(--settings-radius-control); + color: var(--text-weak); + background: transparent; +} +.settings-icon-action:hover, +.settings-icon-action[data-pinned="true"] { + color: var(--text-strong); + background: var(--settings-surface-hover); +} +.settings-provider-key { + min-height: 32px; + padding-block: 5px; + font-family: var(--font-family-mono); +} + +@media (max-width: 640px) { + .settings-choice-grid { + grid-template-columns: 1fr; + } +} +.settings-avatar { + width: 32px; + height: 32px; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--icon-strong-base); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: 1; +} +.settings-avatar[data-tinted="true"] { + border-color: transparent; +} +.settings-provider-logo { + position: relative; + width: 32px; + height: 32px; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + overflow: hidden; + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--text-strong); +} +.settings-provider-logo[data-size="small"] { + width: 24px; + height: 24px; + border-radius: calc(var(--settings-radius-control) - 2px); +} +.settings-chip { + min-height: 20px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + padding: 2px 7px; + border: 0; + border-radius: var(--settings-radius-pill); + background: var(--settings-surface-muted); + color: var(--text-weak); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 15px; +} +.settings-toolbar { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.settings-segmented-control { + min-height: 32px; + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} +.settings-segmented-control__option { + min-height: 28px; + border-radius: calc(var(--settings-radius-control) - 2px); + color: var(--text-weak); +} +.settings-segmented-control__option:hover, +.settings-segmented-control__option[data-selected="true"] { + color: var(--text-strong); + background: var(--settings-surface-active); +} +.settings-control { + min-width: 0; + min-height: 32px; + height: 32px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} +.settings-control--search { + min-width: 140px; + flex: 1; + cursor: text; +} +.settings-control--search:focus-within { + border-color: var(--focus-lit-ring); + box-shadow: var(--focus-lit-halo); +} +.settings-control--menu, +.settings-control--primary { + flex: 0 0 auto; + cursor: pointer; +} +.settings-control--menu:hover, +.settings-control--menu[data-expanded] { + background: var(--settings-surface-active); +} +.settings-control--primary { + border-color: var(--border-weak-base); + background: var(--settings-primary); + color: var(--settings-on-primary); +} +.settings-control--primary:hover { + background: var(--settings-primary-hover); +} +.settings-field { + width: 100%; + min-height: 32px; + padding: 5px 10px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + outline: none; + background: var(--settings-surface-muted); + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); +} +.settings-field:focus { + border-color: var(--focus-lit-ring); + box-shadow: var(--focus-lit-halo); +} +.settings-field::placeholder { + color: var(--text-weak); +} +.settings-field--multiline { + min-height: 96px; + resize: vertical; +} +.settings-button { + min-height: 32px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + opacity 140ms ease; +} +.settings-button[data-variant="primary"] { + border-color: var(--border-weak-base); + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: var(--shadow-xs-border); +} +.settings-button[data-variant="primary"]:hover { + background: var(--settings-primary-hover); +} +.settings-button[data-variant="ghost"] { + border-color: transparent; + color: var(--text-weak); + background: transparent; +} +.settings-button[data-variant="ghost"]:hover { + color: var(--text-strong); + background: var(--settings-surface-hover); +} +.settings-button[data-variant="danger"] { + color: var(--text-on-critical-base); + background: var(--surface-critical-weak); +} +.settings-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +/* Panel-local semantic hooks. These keep icon, status, and action hierarchy + consistent without forcing each Settings page to invent its own colors. */ +.settings-row-icon, +.settings-alert__icon { + width: 32px; + height: 32px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--settings-radius-control); + background: var(--surface-base-hover); + color: var(--icon-strong-base); +} +.settings-alert__icon { + width: 28px; + height: 28px; +} +.settings-status { + min-height: 24px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 0; + border-radius: var(--settings-radius-pill); + color: var(--text-weak); + background: var(--settings-surface-muted); + font-size: 11px; + font-weight: var(--font-weight-medium); +} +.settings-status[data-tone="ready"] { + color: var(--text-strong); +} +.settings-status__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--icon-success-base); +} +.settings-panel-action { + border-radius: var(--settings-radius-control); +} +.settings-panel-action--quiet { + color: var(--text-weak); +} +.settings-panel-action--danger-quiet { + border-color: transparent; + color: var(--text-danger); + background: transparent; +} +.settings-panel-action--danger-quiet:hover:not(:disabled) { + border-color: var(--border-critical-base); + background: var(--surface-critical-weak); +} +.settings-inference-choice { + min-height: 88px; + justify-content: flex-start; +} +.settings-inference-choice__heading { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + width: 100%; +} +.settings-inference-choice__check { + color: var(--text-interactive-base); +} +.settings-account-summary, +.settings-connection-card, +.settings-provider-key-form, +.settings-defaults-card, +.settings-model-catalog { + background: var(--settings-surface); +} +.settings-boundary-note { + align-items: flex-start; + background: var(--settings-surface-muted); +} + +/* Panels use the shared type utility names, but the shell owns their semantic + role. Keep title, row label, body, and helper text distinct instead of + flattening every class (and even inline styles) to one 12px weight. */ +.settings-dialog .text-16-medium { + font-family: inherit; + font-size: var(--settings-type-title); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-title); + letter-spacing: -0.015em; +} +.settings-dialog .settings-page-header h2 { + font-weight: var(--font-weight-medium); +} +.settings-dialog .text-14-medium { + font-family: inherit; + font-size: var(--settings-type-heading); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); + letter-spacing: -0.006em; +} +.settings-dialog .text-13-medium { + font-family: inherit; + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); + letter-spacing: 0; +} +.settings-dialog .text-12-medium { + font-family: inherit; + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-helper); + letter-spacing: 0; +} +.settings-dialog .text-14-regular { + font-family: inherit; + font-size: var(--settings-type-heading); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); + letter-spacing: 0; +} +.settings-dialog .text-13-regular { + font-family: inherit; + font-size: var(--settings-type-body); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-body); + letter-spacing: 0; +} +.settings-dialog :where(.text-12-regular, .text-11-regular, .text-10-regular) { + font-family: inherit; + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-helper); + letter-spacing: 0; +} +.settings-dialog :where(.text-11-medium, .text-10-medium) { + font-family: inherit; + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-helper); + letter-spacing: 0; +} +.settings-dialog [class~="tracking-wide"] { + letter-spacing: 0; +} +.settings-dialog :where(button, [role="button"], [data-slot="select-select-trigger"]) { + min-height: 32px; +} +.settings-dialog :where(.settings-choice, .settings-inference-choice) { + height: auto; + min-height: 88px; +} +.settings-dialog :where(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) { + min-height: 32px; +} +.settings-dialog :where(.text-text-weaker, [class*="text-text-weak/"]) { + color: var(--text-weak); +} +.settings-dialog :where(input, textarea)::placeholder { + color: var(--text-weak); + opacity: 1; } .settings-dialog [data-slot="select-select-trigger"] span { - font-size: 12px !important; - line-height: 18px !important; + font-size: var(--settings-type-body); + line-height: var(--settings-leading-body); } .settings-dialog :where(button, [role="button"]):not(:disabled) { transition: @@ -472,104 +1229,215 @@ const SETTINGS_STYLES = ` transform: scale(0.98); } .settings-dialog :where(button, input, select, textarea):focus-visible { - outline: 1px solid var(--border-strong-base); + outline: 2px solid var(--color-focus); outline-offset: 2px; + transition-duration: 0ms; } -.settings-dialog :where([class*="bg-surface-base"], [class*="bg-surface-raised-base"]) { - background-color: color-mix(in srgb, var(--surface-raised-base) 66%, transparent) !important; -} -.settings-dialog :where([class*="border-border-weak-base"]) { - border-color: color-mix(in srgb, var(--border-weak-base) 82%, transparent) !important; -} -.settings-dialog [class*="rounded-[4px]"], -.settings-dialog [class*="rounded-[6px]"] { - border-radius: 10px !important; +@media (pointer: coarse) { + .settings-dialog :where(button, [role="button"], [data-component="switch"], [data-slot="select-select-trigger"]), + .settings-dialog :where(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) { + min-height: 44px; + } } @media (prefers-reduced-transparency: reduce) { [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-container"], .settings-dialog, - .settings-nav, .settings-main, .settings-main__header { - background: var(--background-base); + background: var(--settings-canvas); backdrop-filter: none; -webkit-backdrop-filter: none; } + .settings-nav { + background: var(--settings-rail); + } } @media (prefers-contrast: more) { [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-container"], .settings-nav, .settings-main__header, - .settings-list { - border-color: var(--border-strong-base); + .settings-card { + border-color: var(--settings-border-strong); + } +} + +/* Generic panels respond to the Settings column rather than the browser. + Panels with bespoke layouts own their intrinsic breakpoints locally. */ +@container settings-main (max-width: 600px) { + .settings-choice-grid, + .credential-form-grid { + grid-template-columns: 1fr; } } -@media (max-width: 720px) { +@container settings-main (max-width: 480px) { + .settings-section-heading { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + .settings-list-row, + .settings-model-row { + align-items: flex-start; + flex-wrap: wrap; + } + .settings-list-actions, + .credential-form-actions { + width: 100%; + flex-wrap: wrap; + padding-left: 44px; + } + .credential-form { + padding-left: 14px; + } +} + +@media (max-width: 980px) { [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-container"] { width: calc(100vw - 16px); - height: calc(100vh - 20px); + height: calc(100vh - 16px); + border-radius: var(--settings-radius-modal); } .settings-layout { flex-direction: column; } .settings-nav { width: 100%; - height: 42px; - flex: 0 0 42px; - padding: 0 4px; + height: 48px; + flex: 0 0 48px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 32px; + align-items: stretch; + padding: 0; border-right: 0; - border-bottom: 1px solid var(--border-weak-base); + border-bottom: 1px solid var(--border-base); overflow: hidden; } + .settings-nav__title { + display: none; + } + .settings-nav__scroll-button { + width: 32px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0; + color: var(--text-weak); + } + .settings-nav__scroll-button:hover { + background: var(--settings-surface-hover); + color: var(--text-strong); + } + .settings-nav__scroll-button:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -3px; + } + .settings-nav__scroll-button:disabled { + color: var(--text-weaker); + cursor: default; + opacity: 0.42; + } + .settings-nav__scroll-button:disabled:hover { + background: transparent; + } .settings-nav__sections { + min-width: 0; + align-items: center; flex-direction: row; - gap: 0; - padding: 0; + gap: 2px; + padding: 0 4px; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + scrollbar-width: thin; + scrollbar-color: var(--border-strong-base) transparent; + } + .settings-nav__sections::-webkit-scrollbar { + display: block; + height: 3px; + } + .settings-nav__sections::-webkit-scrollbar-track { + background: transparent; + } + .settings-nav__sections::-webkit-scrollbar-thumb { + border-radius: 999px; + background: var(--border-strong-base); } .settings-nav__section { flex: 0 0 auto; flex-direction: row; - gap: 0; + gap: 2px; } .settings-nav__label, .settings-nav__footer { display: none; } .settings-nav__item { - height: 42px; + height: 36px; flex: 0 0 auto; gap: 6px; - padding: 0 9px; - border-bottom: 1px solid transparent; - border-radius: 0; + padding: 0 12px; + border: 0; + border-radius: var(--settings-radius-control); font-size: 12px; + scroll-snap-align: start; + white-space: nowrap; } .settings-nav__item:hover { - background: transparent; + background: var(--settings-surface-hover); } .settings-nav__item[data-active="true"] { - border-bottom-color: var(--text-base); - background: transparent; + background: var(--settings-selection); } .settings-main__header { - min-height: 44px; + min-height: 52px; } .settings-page-header { - padding: 18px 18px 14px; + padding: 20px 20px 16px; } .settings-page-body { - padding: 2px 18px 36px; + padding: 16px 20px 36px; + } + .settings-panel-loading__header { + padding: 20px 20px 16px; + } + .settings-panel-loading__body { + padding: 16px 20px 36px; } .credential-form-grid { grid-template-columns: 1fr; } } +@media (max-width: 640px) { + .settings-main__context { + display: none; + } +} + +@media (max-height: 600px) and (min-width: 981px) { + [data-component="dialog"]:has([data-slot="dialog-content"].settings-dialog) [data-slot="dialog-container"] { + height: calc(100vh - 24px); + } + .settings-nav { + padding-block: 12px; + } + .settings-nav__sections { + gap: 12px; + } + .settings-page-header { + padding-block: 16px 14px; + } + .settings-page-body { + gap: 20px; + padding-block: 14px 28px; + } +} + @media (prefers-reduced-motion: reduce) { .settings-dialog *, .settings-dialog *::before, @@ -585,101 +1453,208 @@ const SETTINGS_STYLES = ` export const DialogSettings: Component<{ initial?: SettingsPanelId }> = (props) => { const platform = usePlatform() const dialog = useDialog() + const initial = findPanel(props.initial ?? DEFAULT_PANEL) // Browser-style history so back/forward chevrons are real navigation. - const [history, setHistory] = createSignal<SettingsPanelId[]>([props.initial ?? DEFAULT_PANEL]) + const [history, setHistory] = createSignal<SettingsPanelId[]>([initial.id]) const [cursor, setCursor] = createSignal(0) + const [mounted, setMounted] = createSignal([initial]) + const [pending, setPending] = createSignal<SettingsPanelId>() const [expanded, setExpanded] = createSignal(false) + const [navCanScrollBack, setNavCanScrollBack] = createSignal(false) + const [navCanScrollForward, setNavCanScrollForward] = createSignal(false) + let navSections: HTMLDivElement | undefined + let navigationRequest = 0 const current = createMemo(() => findPanel(history()[cursor()])) const canBack = createMemo(() => cursor() > 0) const canForward = createMemo(() => cursor() < history().length - 1) + onMount(() => { + // Keep the opening interaction light. Preloading every settings chunk at + // once competes with the active panel on slower machines. The most likely + // next destination is warmed during idle time; every other destination is + // prefetched on pointer or keyboard intent below. + const preloadSkills = () => void preloadPanel("skills").catch(() => undefined) + let idleHandle: number | undefined + let timerHandle: ReturnType<typeof setTimeout> | undefined + const requestIdle = (window as Window & { requestIdleCallback?: typeof window.requestIdleCallback }) + .requestIdleCallback + if (typeof requestIdle === "function") { + idleHandle = requestIdle.call(window, preloadSkills, { timeout: 1_200 }) + } else { + timerHandle = globalThis.setTimeout(preloadSkills, 600) + } + const updateNavScroll = () => { + if (!navSections) return + const max = Math.max(0, navSections.scrollWidth - navSections.clientWidth) + setNavCanScrollBack(navSections.scrollLeft > 1) + setNavCanScrollForward(navSections.scrollLeft < max - 1) + } + const observer = new ResizeObserver(updateNavScroll) + if (navSections) { + observer.observe(navSections) + navSections.addEventListener("scroll", updateNavScroll, { passive: true }) + updateNavScroll() + } + onCleanup(() => { + observer.disconnect() + navSections?.removeEventListener("scroll", updateNavScroll) + if (idleHandle !== undefined) window.cancelIdleCallback(idleHandle) + if (timerHandle !== undefined) globalThis.clearTimeout(timerHandle) + }) + }) + + createEffect(() => { + current().id + queueMicrotask(() => + navSections + ?.querySelector<HTMLElement>('.settings-nav__item[data-active="true"]') + ?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }), + ) + }) + const navigate = (id: SettingsPanelId) => { if (history()[cursor()] === id) return + const request = ++navigationRequest const next = history().slice(0, cursor() + 1) next.push(id) - setHistory(next) - setCursor(next.length - 1) + + // Navigation acknowledges synchronously. The destination owns its loading + // skeleton through Suspense, so a click never looks ignored while a lazy + // module or its first data request is still settling. + batch(() => { + const panel = findPanel(id) + setMounted((panels) => (panels.some((item) => item.id === id) ? panels : [...panels, panel])) + setHistory(next) + setCursor(next.length - 1) + setPending(id) + }) + + void preloadPanel(id) + .catch(() => undefined) + .finally(() => { + if (request === navigationRequest) setPending(undefined) + }) + } + const moveHistory = (next: number) => { + navigationRequest += 1 + batch(() => { + setPending(undefined) + setCursor(next) + }) + } + const back = () => canBack() && moveHistory(cursor() - 1) + const forward = () => canForward() && moveHistory(cursor() + 1) + const scrollNav = (direction: -1 | 1) => { + if (!navSections) return + const distance = Math.min(navSections.clientWidth * 0.72, 360) + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches + navSections.scrollBy({ left: direction * distance, behavior: reduced ? "auto" : "smooth" }) } - const back = () => canBack() && setCursor(cursor() - 1) - const forward = () => canForward() && setCursor(cursor() + 1) return ( - <Dialog size="x-large" transition class="settings-dialog" classList={{ "settings-expanded": expanded() }}> + <> <style>{SETTINGS_STYLES}</style> - <div class="settings-layout"> - {/* ── Left rail ── */} - <nav class="settings-nav"> - <div class="settings-nav__sections"> - <For each={SETTINGS_SECTIONS}> - {(section) => ( - <div class="settings-nav__section"> - <span class="settings-nav__label">{section.label}</span> - <For each={SETTINGS_PANELS.filter((p) => p.section === section.id)}> - {(panel) => ( - <button - type="button" - class="settings-nav__item" - data-active={current().id === panel.id ? "true" : "false"} - onClick={() => navigate(panel.id)} - aria-current={current().id === panel.id ? "page" : undefined} - > - <Icon name={panel.icon} size="small" class="flex-shrink-0" /> - <span class="truncate">{panel.title}</span> - </button> - )} - </For> - </div> - )} - </For> - </div> - <div class="settings-nav__footer"> - <span class="text-12-medium">OpenScience</span> - <span class="text-11-regular">v{platform.version}</span> - </div> - </nav> - - {/* ── Right column ── */} - <div class="settings-main"> - {/* Header */} - <header class="settings-main__header"> - <div class="flex items-center gap-1 min-w-0"> - <IconButton icon="arrow-left" variant="ghost" disabled={!canBack()} onClick={back} aria-label="Back" /> - <IconButton - icon="arrow-right" - variant="ghost" - disabled={!canForward()} - onClick={forward} - aria-label="Forward" - /> - <span class="settings-main__title">{current().title}</span> + <Dialog + title="Settings" + action={<span aria-hidden="true" />} + size="x-large" + class="settings-dialog" + classList={{ "settings-expanded": expanded() }} + > + <div class="settings-layout"> + {/* ── Left rail ── */} + <nav class="settings-nav" aria-label="Settings sections"> + <div class="settings-nav__title">Settings</div> + <button + type="button" + class="settings-nav__scroll-button" + aria-label="Earlier settings sections" + disabled={!navCanScrollBack()} + onClick={() => scrollNav(-1)} + > + <Icon name="chevron-left" size="small" /> + </button> + <div class="settings-nav__sections" ref={navSections}> + <For each={SETTINGS_SECTIONS}> + {(section) => ( + <div class="settings-nav__section"> + <span class="settings-nav__label">{section.label}</span> + <For each={SETTINGS_PANELS.filter((p) => p.section === section.id)}> + {(panel) => ( + <button + type="button" + class="settings-nav__item" + data-active={current().id === panel.id ? "true" : "false"} + data-pending={pending() === panel.id ? "true" : undefined} + onPointerEnter={() => void preloadPanel(panel.id).catch(() => undefined)} + onFocus={() => void preloadPanel(panel.id).catch(() => undefined)} + onClick={() => void navigate(panel.id)} + aria-busy={pending() === panel.id ? "true" : undefined} + aria-current={current().id === panel.id ? "page" : undefined} + > + <Icon name={panel.icon} size="normal" class="flex-shrink-0" /> + <span class="truncate">{panel.title}</span> + </button> + )} + </For> + </div> + )} + </For> </div> - <div class="flex items-center gap-1 flex-shrink-0"> - <IconButton - icon={expanded() ? "collapse" : "expand"} - variant="ghost" - onClick={() => setExpanded((v) => !v)} - aria-label={expanded() ? "Collapse" : "Expand"} - /> - <IconButton icon="close" variant="ghost" onClick={() => dialog.close()} aria-label="Close" /> + <button + type="button" + class="settings-nav__scroll-button" + aria-label="Later settings sections" + disabled={!navCanScrollForward()} + onClick={() => scrollNav(1)} + > + <Icon name="chevron-right" size="small" /> + </button> + <div class="settings-nav__footer"> + <span class="text-12-medium">OpenScience</span> + <span class="text-11-regular">v{platform.version}</span> </div> - </header> + </nav> - {/* Body */} - <div class="flex flex-col flex-1 min-h-0 overflow-hidden"> - <Suspense - fallback={ - <div class="flex flex-1 items-center justify-center text-13-regular text-text-weak">Loading…</div> - } - > - <SettingsNavContext.Provider value={navigate}> - <Dynamic component={current().component} /> + {/* ── Right column ── */} + <div class="settings-main"> + {/* Header */} + <header class="settings-main__header"> + <div class="flex items-center gap-1 min-w-0"> + <IconButton icon="arrow-left" variant="ghost" disabled={!canBack()} onClick={back} aria-label="Back" /> + <IconButton + icon="arrow-right" + variant="ghost" + disabled={!canForward()} + onClick={forward} + aria-label="Forward" + /> + </div> + <div class="settings-main__context" aria-live="polite"> + <span>{current().title}</span> + </div> + <div class="flex items-center gap-1 flex-shrink-0"> + <IconButton + icon={expanded() ? "collapse" : "expand"} + variant="ghost" + onClick={() => setExpanded((v) => !v)} + aria-label={expanded() ? "Collapse" : "Expand"} + /> + <IconButton icon="close" variant="ghost" onClick={() => dialog.close()} aria-label="Close" /> + </div> + </header> + + {/* Body */} + <div class="settings-main__viewport" data-panel={current().id}> + <SettingsNavContext.Provider value={(id) => void navigate(id)}> + <SettingsPanelStack active={() => current().id} panels={mounted} /> </SettingsNavContext.Provider> - </Suspense> + </div> </div> </div> - </div> - </Dialog> + </Dialog> + </> ) } diff --git a/frontend/workspace/src/components/interaction-radius-contract.test.ts b/frontend/workspace/src/components/interaction-radius-contract.test.ts new file mode 100644 index 00000000..dc344919 --- /dev/null +++ b/frontend/workspace/src/components/interaction-radius-contract.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" + +const files = { + composer: await Bun.file(new URL("./prompt-input.css", import.meta.url)).text(), + chat: await Bun.file(new URL("./chat-surface.css", import.meta.url)).text(), + settings: await Bun.file(new URL("./model-settings-popover.css", import.meta.url)).text(), + models: await Bun.file(new URL("./dialog-select-model.css", import.meta.url)).text(), + servers: await Bun.file(new URL("./dialog-select-server.css", import.meta.url)).text(), +} + +describe("live interaction radius contract", () => { + test("routes every non-zero radius through a semantic token", () => { + for (const [name, css] of Object.entries(files)) { + const radii = [...css.matchAll(/border-radius:\s*([^;]+);/g)].map((match) => match[1]!.replace(/\s+/g, " ")) + + expect(radii.length, `${name} should own at least one radius`).toBeGreaterThan(0) + for (const radius of radii) { + expect(radius, `${name}: ${radius}`).not.toMatch(/\d+(?:\.\d+)?px/) + expect(radius, `${name}: ${radius}`).toMatch(/var\(--|^(?:0|50%|inherit)$/) + } + } + }) + + test("keeps pills and asymmetric message or sheet shapes explicit", () => { + expect(files.composer).toContain("--composer-radius-pill: 999px") + expect(files.composer).toContain("border-radius: var(--composer-radius-pill)") + expect(files.servers).toContain("--server-radius-pill: 999px") + expect(files.servers).toContain("border-radius: var(--server-radius-pill)") + expect(files.chat).toContain("--user-message-radius: var(--radius-lg)") + expect(files.chat).toContain("--user-message-tail-radius: var(--radius-xs)") + expect(files.settings).toContain("border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important") + expect(files.models).toContain("border-radius: var(--radius-xl) var(--radius-xl) 0 0") + }) + + test("keeps necessary positioning overrides while avoiding cascade-force for local edges", () => { + expect(files.composer).toContain("outline: 2px solid var(--color-focus);") + expect(files.composer).not.toContain("outline: 2px solid var(--color-focus) !important") + expect(files.settings).toContain( + '[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]', + ) + expect(files.settings).toContain("border-radius: var(--radius-md);") + expect(files.settings).not.toContain("border-radius: var(--radius-md) !important") + expect(files.models).toContain('.model-picker-sheet [data-component="button"].model-picker-sheet__manage') + expect(files.servers).toContain("margin: 0;") + expect(files.servers).not.toContain("margin: 0 !important") + expect(files.settings).toContain("transform: none !important") + }) + + test("uses solid semantic structure and limits tint mixing to restrained state and common-region surfaces", () => { + expect(files.composer.match(/color-mix/g)).toHaveLength(1) + expect(files.chat.match(/color-mix/g)).toHaveLength(4) + expect(files.chat).toContain("border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent)") + expect(files.settings.match(/color-mix/g)).toHaveLength(1) + expect(files.models).not.toContain("color-mix") + expect(files.servers.match(/color-mix/g)).toHaveLength(2) + + expect(files.composer).toContain("border: 1px solid var(--border-weak-base)") + expect(files.chat).toContain("box-shadow: none") + expect(files.servers).toContain("border: 1px solid var(--border-base)") + expect(files.servers).toContain("border-top: 1px solid var(--border-weak-base)") + expect(files.settings).toContain("box-shadow: var(--atlas-shadow-float)") + expect(files.models).toContain("box-shadow: var(--atlas-shadow-float)") + expect(Object.values(files).join("\n")).not.toContain("#000") + }) +}) diff --git a/frontend/workspace/src/components/mobile-compose-model.test.ts b/frontend/workspace/src/components/mobile-compose-model.test.ts index 34616430..d8cbfdc1 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -94,7 +94,7 @@ describe("mobile compose and model sheets", () => { expect(css).toContain("width: 100% !important") expect(css).toContain("transform: none !important") expect(css).toContain("inset: auto 0 0 !important") - expect(css).toContain("border-radius: 16px 16px 0 0 !important") + expect(css).toContain("border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important") }) test("renders model discovery as a scrollable edge-to-edge mobile dialog", async () => { @@ -104,7 +104,7 @@ describe("mobile compose and model sheets", () => { expect(picker).toContain('class="model-picker-sheet__list"') expect(picker).toContain("onSelect={() => dialog.close()}") expect(css).toContain("width: 100%") - expect(css).toContain("border-radius: 20px 20px 0 0") + expect(css).toContain("border-radius: var(--radius-xl) var(--radius-xl) 0 0") expect(css).toContain("min-height: 63px") expect(css).toContain("font-size: 16px") expect(css).toContain("font-size: 13px") diff --git a/frontend/workspace/src/components/model-quick.test.ts b/frontend/workspace/src/components/model-quick.test.ts new file mode 100644 index 00000000..01b38792 --- /dev/null +++ b/frontend/workspace/src/components/model-quick.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { curateQuickModels } from "./model-quick" + +const model = ( + id: string, + provider: string, + options?: { latest?: boolean; reasoning?: boolean; released?: string }, +) => ({ + id, + name: id, + provider: { id: provider, name: provider }, + latest: options?.latest, + release_date: options?.released, + capabilities: { reasoning: options?.reasoning ?? true }, + limit: { context: 200_000 }, +}) + +describe("curated composer models", () => { + test("uses only available models and keeps provider labs diverse", () => { + const openai = model("gpt-live", "openai", { latest: true }) + const openaiOlder = model("gpt-older", "openai") + const anthropic = model("claude-live", "anthropic", { latest: true }) + const google = model("gemini-live", "google", { latest: true }) + + expect( + curateQuickModels({ + pinned: [], + current: openai, + recent: [openaiOlder], + available: [openaiOlder, openai, anthropic, google], + limit: 3, + }).map((item) => item.id), + ).toEqual(["gpt-live", "claude-live", "gemini-live"]) + }) + + test("honors explicit pins and deduplicates routed aliases", () => { + const pinned = model("claude-opus-5", "anthropic") + const routed = model("anthropic/claude-opus-5", "openrouter") + const google = model("gemini-live", "google") + + expect( + curateQuickModels({ pinned: [pinned], current: routed, recent: [], available: [routed, google] }).map( + (item) => item.id, + ), + ).toEqual(["claude-opus-5", "gemini-live"]) + }) +}) diff --git a/frontend/workspace/src/components/model-quick.ts b/frontend/workspace/src/components/model-quick.ts new file mode 100644 index 00000000..f568edd1 --- /dev/null +++ b/frontend/workspace/src/components/model-quick.ts @@ -0,0 +1,70 @@ +import { canonicalKey, displayProviderForModel } from "@/context/model-catalog" + +type QuickModel = { + id: string + name: string + provider: { id: string; name: string } + latest?: boolean + release_date?: string + capabilities: { reasoning: boolean } + limit: { context: number } +} + +type QuickModelInput<T extends QuickModel> = { + pinned: readonly T[] + current?: T + recent: readonly T[] + available: readonly T[] + limit?: number +} + +/** + * Builds a small composer menu from models that are actually available. + * Explicit pins and the current model stay first; the remaining slots favor + * recent, current-generation reasoning models while keeping provider labs + * diverse. No provider or model is invented by the client. + */ +export function curateQuickModels<T extends QuickModel>(input: QuickModelInput<T>) { + const limit = input.limit ?? 4 + const selected: T[] = [] + const keys = new Set<string>() + const labs = new Set<string>() + const recency = new Map(input.recent.map((model, index) => [canonicalKey(model.provider.id, model.id), index])) + const key = (model: T) => canonicalKey(model.provider.id, model.id) + const lab = (model: T) => displayProviderForModel(model.provider, model.id).id + const add = (model: T) => { + const id = key(model) + if (keys.has(id) || selected.length >= limit) return false + selected.push(model) + keys.add(id) + labs.add(lab(model)) + return true + } + + for (const model of input.pinned) add(model) + if (input.current) add(input.current) + if (selected.length >= limit) return selected + + const candidates = input.available + .filter((model) => !keys.has(key(model))) + .slice() + .sort((left, right) => { + const leftRecent = recency.has(key(left)) ? input.recent.length - (recency.get(key(left)) ?? 0) : 0 + const rightRecent = recency.has(key(right)) ? input.recent.length - (recency.get(key(right)) ?? 0) : 0 + return ( + rightRecent - leftRecent || + Number(Boolean(right.latest)) - Number(Boolean(left.latest)) || + (right.release_date ?? "").localeCompare(left.release_date ?? "") || + Number(right.capabilities.reasoning) - Number(left.capabilities.reasoning) || + right.limit.context - left.limit.context || + left.name.localeCompare(right.name) + ) + }) + + for (const model of candidates) { + if (labs.has(lab(model))) continue + add(model) + } + for (const model of candidates) add(model) + return selected +} diff --git a/frontend/workspace/src/components/model-settings-popover.css b/frontend/workspace/src/components/model-settings-popover.css index 8fffb253..31074540 100644 --- a/frontend/workspace/src/components/model-settings-popover.css +++ b/frontend/workspace/src/components/model-settings-popover.css @@ -13,7 +13,7 @@ --model-control-text: var(--text-strong); --model-control-muted: var(--text-weak); --model-control-faint: var(--text-weaker); - --model-control-shadow: 0 16px 42px color-mix(in srgb, #241f1a 18%, transparent); + --model-control-shadow: var(--atlas-shadow-md); font-family: var( --font-family-sans, "Inter Variable", @@ -27,50 +27,36 @@ ); } -html[data-color-scheme="dark"] - :where([data-model-settings-trigger], [data-model-settings-popover], [data-model-effort-chip]) { - --model-control-surface: #30302d; - --model-control-raised: #353532; - --model-control-hover: #3a3a36; - --model-control-selected: #40403a; - --model-control-border: #ffffff17; - --model-control-border-strong: #ffffff26; - --model-control-text: #f2f1ec; - --model-control-muted: #b6b5ae; - --model-control-faint: #8f8e87; - --model-control-shadow: 0 18px 46px #0000004a, 0 2px 8px #00000029; -} - -[data-model-settings-trigger-style="label"] { - min-height: 32px !important; +[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + min-height: 32px; max-width: min(210px, 40vw); - gap: 6px !important; - padding: 0 9px !important; - border: 1px solid var(--model-control-border) !important; - border-radius: 8px !important; - background: var(--model-control-raised) !important; - box-shadow: none !important; - color: var(--model-control-text) !important; - font-size: 13px !important; - font-weight: 500 !important; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--model-control-border); + border-radius: var(--radius-xs); + background: var(--model-control-raised); + box-shadow: none; + color: var(--model-control-text); + font-size: 13px; + font-weight: var(--font-weight-regular); letter-spacing: -0.005em; } -[data-model-settings-trigger-style="label"]:hover, -[data-model-settings-trigger-style="label"]:focus-visible, -[data-model-settings-trigger-style="label"][data-expanded] { - background: var(--model-control-hover) !important; +[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]:hover:not(:disabled), +[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]:focus-visible:not(:disabled), +[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"][data-expanded] { + background: var(--model-control-hover); } -[data-model-settings-trigger-style="label"]:focus-visible { - outline: 2px solid #4f8cff !important; +[data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]:focus-visible { + outline: 2px solid var(--color-focus); outline-offset: 1px; } [data-model-source-label] { color: var(--model-control-muted); font-size: 11px; - font-weight: 400; + font-weight: var(--font-weight-regular); letter-spacing: 0; white-space: nowrap; } @@ -79,11 +65,11 @@ html[data-color-scheme="dark"] min-height: 30px; padding: 0 7px; border: 0; - border-radius: 7px; + border-radius: var(--radius-xs); background: transparent; color: var(--model-control-muted); font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-regular); letter-spacing: -0.005em; white-space: nowrap; cursor: pointer; @@ -98,14 +84,14 @@ html[data-color-scheme="dark"] [data-model-settings-popover] { box-sizing: border-box; - width: min(320px, calc(100vw - 24px)) !important; + width: min(320px, calc(100vw - 24px)); max-height: min(72dvh, 440px); overflow: hidden; - padding: 6px !important; - border: 1px solid var(--model-control-border-strong) !important; - border-radius: 12px !important; - background: var(--model-control-surface) !important; - box-shadow: var(--model-control-shadow) !important; + padding: 6px; + border: 1px solid var(--model-control-border-strong); + border-radius: var(--radius-md); + background: var(--model-control-surface); + box-shadow: var(--model-control-shadow); color: var(--model-control-text); } @@ -113,28 +99,32 @@ html[data-color-scheme="dark"] min-height: 36px; gap: 10px; padding: 0 10px; - border-radius: 8px; + border-radius: var(--radius-xs); color: var(--model-control-text); font-size: 12px; line-height: 1.2; } +[data-model-settings-popover] .model-settings-row:has(.model-settings-setting) { + min-height: 50px; +} + [data-model-settings-popover] .model-settings-row:hover, [data-model-settings-popover] .model-settings-row:focus-visible { background: var(--model-control-hover); } [data-model-settings-popover] .model-settings-row:focus-visible { - outline: 1px solid #4f8cff !important; + outline: 1px solid var(--color-focus); outline-offset: -1px; - box-shadow: none !important; + box-shadow: none; } [data-model-menu-label] { min-width: 0; color: var(--model-control-text); font-size: 13px; - font-weight: 500; + font-weight: var(--font-weight-regular); letter-spacing: -0.006em; } @@ -146,10 +136,9 @@ html[data-color-scheme="dark"] .model-settings-heading { padding: 6px 10px 4px; color: var(--model-control-faint); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; + font-size: 12px; + font-weight: var(--font-weight-regular); + letter-spacing: 0; } [data-model-settings-popover] [data-model-quick] { @@ -175,19 +164,59 @@ html[data-color-scheme="dark"] .model-settings-model strong { color: var(--model-control-text); font-size: 13px; - font-weight: 520; + font-weight: var(--font-weight-medium); letter-spacing: -0.005em; } +.model-settings-model-heading { + min-width: 0; + display: flex; + align-items: baseline; + gap: 7px; +} + +.model-settings-model-heading strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-settings-provider { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--model-control-muted); + font-size: 10.5px; + font-weight: var(--font-weight-regular); + line-height: 1; +} + +.model-settings-provider [data-component="provider-icon"] { + width: 12px; + height: 12px; +} + +.model-settings-provider > span[aria-hidden="true"] { + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--model-control-border); + border-radius: var(--radius-xs); + font-size: 8px; +} + .model-settings-model small { color: var(--model-control-faint); font-size: 11px; - font-weight: 400; + font-weight: var(--font-weight-regular); } .model-settings-more { - min-height: 40px !important; - color: var(--model-control-text) !important; + color: var(--model-control-text); } .model-settings-browser { @@ -219,7 +248,7 @@ html[data-color-scheme="dark"] margin: 2px 6px 5px; padding: 0 9px; border: 1px solid var(--model-control-border); - border-radius: 8px; + border-radius: var(--radius-xs); background: var(--model-control-raised); color: var(--model-control-faint); } @@ -257,6 +286,8 @@ html[data-color-scheme="dark"] [data-model-settings-popover] [data-model-catalog-item] { min-height: 46px; + content-visibility: auto; + contain-intrinsic-size: auto 46px; } .model-settings-empty { @@ -268,9 +299,18 @@ html[data-color-scheme="dark"] text-align: center; } +.model-settings-catalog-progress { + min-height: 34px; + margin: 0; + padding: 8px 14px; + color: var(--model-control-faint); + font-size: 11px; + line-height: 18px; + text-align: center; +} + .model-settings-manage { - min-height: 38px !important; - flex: 0 0 38px; + flex: 0 0 50px; background: var(--model-control-surface); } @@ -279,7 +319,25 @@ html[data-color-scheme="dark"] gap: 8px; color: var(--model-control-muted); font-size: 12.5px; - font-weight: 400; + font-weight: var(--font-weight-regular); +} + +.model-settings-setting { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.model-settings-setting small { + overflow: hidden; + color: var(--model-control-faint); + font-size: 11px; + font-weight: var(--font-weight-regular); + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; } .model-settings-divider { @@ -290,7 +348,7 @@ html[data-color-scheme="dark"] .model-settings-check { flex: 0 0 auto; - color: #4f8cff; + color: var(--text-interactive-base); } @media (max-width: 719px) { @@ -304,18 +362,20 @@ html[data-color-scheme="dark"] pointer-events: none; } - [data-model-settings-trigger-style="icon"] { - padding: 0 !important; - border-radius: 9px !important; - background: transparent !important; - color: var(--color-text-muted) !important; - box-shadow: none !important; + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="icon"] { + padding: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + box-shadow: none; } - [data-model-settings-trigger-style="icon"]:hover, - [data-model-settings-trigger-style="icon"]:focus-visible { - background: var(--color-bg-subtle) !important; - color: var(--text-strong) !important; + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="icon"]:hover:not(:disabled), + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="icon"]:focus-visible:not( + :disabled + ) { + background: var(--color-bg-subtle); + color: var(--text-strong); } [data-mobile-model-settings-overlay] { @@ -341,8 +401,8 @@ html[data-color-scheme="dark"] border-right: 0; border-bottom: 0; border-left: 0; - border-radius: 16px 16px 0 0 !important; - box-shadow: 0 -18px 48px color-mix(in srgb, #000 20%, transparent); + border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important; + box-shadow: var(--atlas-shadow-float); pointer-events: auto; } @@ -359,7 +419,7 @@ html[data-color-scheme="dark"] .mobile-model-settings__header [data-kobalte-popover-title] { color: var(--text-strong); font-size: 16px; - font-weight: 600; + font-weight: var(--font-weight-medium); letter-spacing: -0.01em; } @@ -404,7 +464,7 @@ html[data-color-scheme="dark"] .model-settings-heading { padding: 10px 20px 5px; - font-size: 11px; + font-size: 12px; } [data-model-settings-popover] details { diff --git a/frontend/workspace/src/components/model-settings-popover.test.ts b/frontend/workspace/src/components/model-settings-popover.test.ts index 3a613c08..4a0c3fa3 100644 --- a/frontend/workspace/src/components/model-settings-popover.test.ts +++ b/frontend/workspace/src/components/model-settings-popover.test.ts @@ -83,6 +83,22 @@ describe("compact model descriptions", () => { }) }) +describe("progressive model catalog", () => { + test("preserves group order while limiting the initial DOM work", () => { + const groups: Array<[string, number[]]> = [ + ["Pinned", [1, 2]], + ["Frontier", [3, 4, 5]], + ["Other", [6, 7]], + ] + + expect(subject.takeCatalogGroups(groups, 4)).toEqual([ + ["Pinned", [1, 2]], + ["Frontier", [3, 4]], + ]) + expect(subject.takeCatalogGroups(groups, 0)).toEqual([]) + }) +}) + describe("model option keyboard navigation", () => { test.each([ ["effort", ["standard", "high", "xhigh"]], diff --git a/frontend/workspace/src/components/model-settings-popover.tsx b/frontend/workspace/src/components/model-settings-popover.tsx index b37e8585..f934eb90 100644 --- a/frontend/workspace/src/components/model-settings-popover.tsx +++ b/frontend/workspace/src/components/model-settings-popover.tsx @@ -3,17 +3,38 @@ import { createMediaQuery } from "@solid-primitives/media" import { Button } from "@synsci/ui/button" import { Icon } from "@synsci/ui/icon" import { IconButton } from "@synsci/ui/icon-button" +import { ProviderIcon } from "@synsci/ui/provider-icon" +import { iconNames, type IconName } from "@synsci/ui/icons/provider" import { useDialog } from "@synsci/ui/context/dialog" -import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type Component } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type Component } from "solid-js" import { useLocal } from "@/context/local" -import { canonicalKey, displayProviderForModel, modelSummary } from "@/context/model-catalog" -import { RECOMMENDED_MODELS } from "@/context/models" +import { displayProviderForModel, modelContext, modelSummary } from "@/context/model-catalog" import { DialogSettings } from "./dialog-settings" import { modelGroup, modelGroupLabel, modelGroupLabelRank } from "./model-groups" import { modelControl } from "./model-presentation" +import { curateQuickModels } from "./model-quick" import "./model-settings-popover.css" const row = "model-settings-row flex w-full min-w-0 items-center justify-between text-left transition-colors" +const CATALOG_FIRST_CHUNK = 24 +const CATALOG_CHUNK = 32 + +export function takeCatalogGroups<T>(groups: Array<[string, T[]]>, limit: number): Array<[string, T[]]> { + let remaining = Math.max(0, limit) + const result: Array<[string, T[]]> = [] + for (const [label, models] of groups) { + if (remaining <= 0) break + const visible = models.slice(0, remaining) + if (visible.length > 0) result.push([label, visible]) + remaining -= visible.length + } + return result +} + +const providerIcon = (id: string) => { + const alias = id === "meta" ? "llama" : id === "openai-codex" ? "openai" : id + return iconNames.includes(alias as IconName) ? (alias as IconName) : undefined +} export type InferenceSource = "managed" | "byok" | "chatgpt" @@ -143,34 +164,26 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( const [open, setOpen] = createSignal(false) const [view, setView] = createSignal<"root" | "models" | "effort" | "speed">("root") const [query, setQuery] = createSignal("") + const [catalogQuery, setCatalogQuery] = createSignal("") + const [catalogReady, setCatalogReady] = createSignal(false) + const [catalogLimit, setCatalogLimit] = createSignal(CATALOG_FIRST_CHUNK) const [notice, setNotice] = createSignal("") const refs = { content: undefined as HTMLElement | undefined } const current = createMemo(() => local.model.current()) - const recommended = createMemo(() => { - const models = local.model.list() - return RECOMMENDED_MODELS.map((item) => { - const key = canonicalKey(item.providerID, item.modelID) - return models.find((model) => canonicalKey(model.provider.id, model.id) === key) - }).filter((model): model is NonNullable<typeof model> => Boolean(model)) - }) const quick = createMemo(() => { - // Pins are explicit. New installations instead see a calm recommended trio - // with the current model retained when it falls outside that set. - const models = [...local.model.pinned(), ...recommended(), current()].filter( - (model): model is NonNullable<typeof model> => Boolean(model), - ) - const seen = new Set<string>() - return models - .filter((model) => { - const key = canonicalKey(model.provider.id, model.id) - if (seen.has(key)) return false - seen.add(key) - return true - }) - .slice(0, 5) + const pinned = local.model.pinned().filter((model): model is NonNullable<typeof model> => Boolean(model)) + const recent = local.model.recent().filter((model): model is NonNullable<typeof model> => Boolean(model)) + const available = local.model + .list() + .filter( + (model) => + local.model.pin.has({ providerID: model.provider.id, modelID: model.id }) || + local.model.visible({ providerID: model.provider.id, modelID: model.id }), + ) + return curateQuickModels({ pinned, current: current(), recent, available }) }) const catalog = createMemo(() => { - const value = query().trim().toLowerCase() + const value = catalogQuery().trim().toLowerCase() return local.model .list() .filter( @@ -197,6 +210,67 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( ([left], [right]) => modelGroupLabelRank(left) - modelGroupLabelRank(right) || left.localeCompare(right), ) }) + const visibleGroups = createMemo(() => (catalogReady() ? takeCatalogGroups(groups(), catalogLimit()) : [])) + + let prepareFrame = 0 + let preparePaintFrame = 0 + let searchTimer = 0 + + const prepareCatalog = () => { + if (catalogReady() || prepareFrame || preparePaintFrame) return + // Two frames guarantee the lightweight browser shell reaches the screen + // before deriving the full catalog. Usually this work is already warm by + // the time the reader chooses More models. + prepareFrame = requestAnimationFrame(() => { + prepareFrame = 0 + preparePaintFrame = requestAnimationFrame(() => { + preparePaintFrame = 0 + catalog() + groups() + setCatalogReady(true) + }) + }) + } + + createEffect(() => { + if (open()) { + prepareCatalog() + return + } + window.clearTimeout(searchTimer) + if (prepareFrame) cancelAnimationFrame(prepareFrame) + if (preparePaintFrame) cancelAnimationFrame(preparePaintFrame) + prepareFrame = 0 + preparePaintFrame = 0 + setCatalogReady(false) + }) + + createEffect(() => { + if (view() !== "models" || !catalogReady()) return + catalogQuery() + const total = catalog().length + setCatalogLimit(Math.min(CATALOG_FIRST_CHUNK, total)) + if (total <= CATALOG_FIRST_CHUNK) return + let frame = requestAnimationFrame(function load() { + setCatalogLimit((current) => Math.min(total, current + CATALOG_CHUNK)) + if (catalogLimit() < total) frame = requestAnimationFrame(load) + }) + onCleanup(() => cancelAnimationFrame(frame)) + }) + + const searchCatalog = (value: string) => { + setQuery(value) + window.clearTimeout(searchTimer) + // Keep keystrokes immediate, then do the catalog-wide filter once the + // short burst settles. This is state deferral, not visible animation. + searchTimer = window.setTimeout(() => setCatalogQuery(value), 70) + } + + onCleanup(() => { + if (prepareFrame) cancelAnimationFrame(prepareFrame) + if (preparePaintFrame) cancelAnimationFrame(preparePaintFrame) + window.clearTimeout(searchTimer) + }) const control = createMemo(() => modelControl({ name: current()?.name ?? "Select model", @@ -246,8 +320,12 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( } const choose = () => { + window.clearTimeout(searchTimer) setQuery("") + setCatalogQuery("") + setCatalogLimit(CATALOG_FIRST_CHUNK) setView("models") + prepareCatalog() focus("[data-model-catalog-search]", true) } @@ -285,8 +363,15 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( onOpenChange={(next) => { setOpen(next) if (!next) { + window.clearTimeout(searchTimer) + if (prepareFrame) cancelAnimationFrame(prepareFrame) + if (preparePaintFrame) cancelAnimationFrame(preparePaintFrame) + prepareFrame = 0 + preparePaintFrame = 0 setView("root") setQuery("") + setCatalogQuery("") + setCatalogReady(false) } }} modal={mobile()} @@ -311,9 +396,7 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( fallback={ <> <span class="truncate">{control().trigger}</span> - <span aria-hidden="true" class="shrink-0 text-text-weak"> - ⌄ - </span> + <Icon name="chevron-down" size="small" class="shrink-0 text-text-weak" /> </> } > @@ -363,7 +446,7 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( <Match when={view() === "root"}> <div data-model-menu-scope class="flex flex-col"> <div class="model-settings-models" role="radiogroup" aria-label="Model"> - <p class="model-settings-heading">Quick models</p> + <p class="model-settings-heading">Suggested models</p> <For each={quick()}> {(model) => { const selected = () => @@ -389,13 +472,21 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( }} > <span class="model-settings-model"> - <strong>{model.name}</strong> + <span class="model-settings-model-heading"> + <strong>{model.name}</strong> + <span class="model-settings-provider"> + <Show + when={providerIcon(displayProviderForModel(model.provider, model.id).id)} + fallback={<span aria-hidden="true">{provider().charAt(0).toUpperCase()}</span>} + > + {(icon) => <ProviderIcon id={icon()} aria-hidden="true" />} + </Show> + {provider()} + </span> + </span> <small> - {modelSummary({ - reasoning: model.capabilities.reasoning, - context: model.limit.context, - provider: provider(), - })} + {model.capabilities.reasoning ? "Reasoning · " : ""} + {modelContext(model.limit.context)} context </small> </span> <Show when={selected()}> @@ -414,7 +505,10 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( class={`${row} model-settings-more`} onClick={choose} > - <span data-model-menu-label>More models</span> + <span class="model-settings-setting"> + <span data-model-menu-label>More models</span> + <small>Browse models from your connected providers.</small> + </span> <span aria-hidden="true" data-model-menu-value> › </span> @@ -432,7 +526,10 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( aria-expanded={view() === "effort"} onClick={() => show("effort")} > - <span data-model-menu-label>{effort().label}</span> + <span class="model-settings-setting"> + <span data-model-menu-label>{effort().label}</span> + <small>Choose how deeply the model reasons.</small> + </span> <span data-model-menu-value class="flex min-w-0 items-center"> <span class="truncate">{effort().value}</span> <span aria-hidden="true">›</span> @@ -452,7 +549,10 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( aria-expanded={view() === "speed"} onClick={() => show("speed")} > - <span data-model-menu-label>{speed().label}</span> + <span class="model-settings-setting"> + <span data-model-menu-label>{speed().label}</span> + <small>Choose the provider's latency tier.</small> + </span> <span data-model-menu-value class="flex min-w-0 items-center"> <span class="truncate">{speed().value}</span> <span aria-hidden="true">›</span> @@ -481,63 +581,73 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( data-model-catalog-search type="search" value={query()} - onInput={(event) => setQuery(event.currentTarget.value)} + onInput={(event) => searchCatalog(event.currentTarget.value)} placeholder="Find a model or provider" aria-label="Find a model or provider" /> </label> <div class="model-settings-catalog" role="radiogroup" aria-label="Available models"> - <For each={groups()}> - {(group) => ( - <section class="model-settings-group" aria-label={group[0]}> - <p class="model-settings-heading">{group[0]}</p> - <For each={group[1]}> - {(model) => { - const selected = () => - current()?.provider.id === model.provider.id && current()?.id === model.id - return ( - <button - type="button" - role="radio" - data-model-menu-item - data-model-catalog-item - aria-checked={selected()} - class={row} - onClick={() => { - local.model.set( - { providerID: model.provider.id, modelID: model.id }, - { recent: true }, - ) - setOpen(false) - }} - > - <span class="model-settings-model"> - <strong>{model.name}</strong> - <small> - {modelSummary({ - reasoning: model.capabilities.reasoning, - context: model.limit.context, - provider: displayProviderForModel(model.provider, model.id).name, - })} - </small> - </span> - <Show when={selected()}> - <Icon name="check" size="small" class="model-settings-check" aria-hidden="true" /> - </Show> - </button> - ) - }} - </For> - </section> - )} - </For> - <Show when={catalog().length === 0}> - <p class="model-settings-empty">No models match “{query()}”.</p> + <Show when={catalogReady()} fallback={<p class="model-settings-empty">Loading models…</p>}> + <For each={visibleGroups()}> + {(group) => ( + <section class="model-settings-group" aria-label={group[0]}> + <p class="model-settings-heading">{group[0]}</p> + <For each={group[1]}> + {(model) => { + const selected = () => + current()?.provider.id === model.provider.id && current()?.id === model.id + return ( + <button + type="button" + role="radio" + data-model-menu-item + data-model-catalog-item + aria-checked={selected()} + class={row} + onClick={() => { + local.model.set( + { providerID: model.provider.id, modelID: model.id }, + { recent: true }, + ) + setOpen(false) + }} + > + <span class="model-settings-model"> + <strong>{model.name}</strong> + <small> + {modelSummary({ + reasoning: model.capabilities.reasoning, + context: model.limit.context, + provider: displayProviderForModel(model.provider, model.id).name, + })} + </small> + </span> + <Show when={selected()}> + <Icon name="check" size="small" class="model-settings-check" aria-hidden="true" /> + </Show> + </button> + ) + }} + </For> + </section> + )} + </For> + <Show when={catalog().length === 0}> + <p class="model-settings-empty">No models match “{query()}”.</p> + </Show> + <Show when={catalogLimit() < catalog().length}> + <p class="model-settings-catalog-progress" role="status"> + Loading more models… + </p> + </Show> </Show> </div> <div class="model-settings-divider" /> <button type="button" data-model-menu-item class={`${row} model-settings-manage`} onClick={manage}> - <span data-model-menu-label>Manage models</span> + <span class="model-settings-setting"> + <span data-model-menu-label>Manage models</span> + <small>Choose which connected models appear here.</small> + </span> <span aria-hidden="true" data-model-menu-value> › </span> diff --git a/frontend/workspace/src/components/model-surface.test.ts b/frontend/workspace/src/components/model-surface.test.ts index d4703fdb..2af40162 100644 --- a/frontend/workspace/src/components/model-surface.test.ts +++ b/frontend/workspace/src/components/model-surface.test.ts @@ -56,13 +56,18 @@ describe("model control surface", () => { expect(settings).toContain('<DialogSettings initial="models" />') expect(settings).not.toContain("<DialogManageModels />") expect(settings).toContain("model-settings-trigger--label") + expect(settings).toContain('<Icon name="chevron-down"') + expect(settings).not.toContain("⌄") expect(settings).toContain("data-model-quick") expect(settings).toContain("modelSummary") expect(settings).not.toContain("Balanced research, coding, and tool use") expect(settings).not.toContain("Deep analysis and scientific review") expect(settings).not.toContain("Long-context research and synthesis") expect(settings).toContain("More models") - expect(settings).toContain("Quick models") + expect(settings).toContain("Suggested models") + expect(settings).toContain("curateQuickModels") + expect(settings).toContain("<ProviderIcon") + expect(settings).not.toContain("RECOMMENDED_MODELS") expect(settings).toContain("Find a model or provider") expect(settings).toContain("Manage models") expect(settings).not.toContain("data-model-source-label") @@ -71,9 +76,12 @@ describe("model control surface", () => { expect(styles).toContain("overflow-y: auto") expect(styles).toContain("min-height: 58px") expect(styles).toContain("font-size: 14px") - expect(styles).toContain("color: #4f8cff") - expect(styles).toContain("--model-control-surface: #30302d") + expect(styles).toContain("color: var(--text-interactive-base)") + expect(styles).not.toContain("--model-control-surface: #30302d") expect(styles).toContain("font-family: var(") + expect(styles).toContain("font-weight: var(--font-weight-regular)") + expect(styles).not.toMatch(/font-weight:\s*(?:400|450|500|550|600|700)/) + expect(styles).not.toContain("text-transform:") const model = settings.indexOf('data-model-menu-row="model"') const quick = settings.indexOf("data-model-quick") diff --git a/frontend/workspace/src/components/prompt-attachment.test.ts b/frontend/workspace/src/components/prompt-attachment.test.ts index c8c25910..d420bd5a 100644 --- a/frontend/workspace/src/components/prompt-attachment.test.ts +++ b/frontend/workspace/src/components/prompt-attachment.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { ATTACHMENT_ACCEPT, MAX_ATTACHMENT_BYTES, attachmentMime, attachmentSize } from "./prompt-attachment" +import { + ATTACHMENT_ACCEPT, + MAX_ATTACHMENT_BYTES, + attachmentFormat, + attachmentMime, + attachmentSize, +} from "./prompt-attachment" describe("prompt attachments", () => { test("accepts scientific text and data files even when the browser omits MIME", () => { @@ -18,4 +24,10 @@ describe("prompt attachments", () => { expect(attachmentSize(12_288)).toBe("12 KB") expect(attachmentSize(2_621_440)).toBe("2.5 MB") }) + + test("keeps the real file extension visible", () => { + expect(attachmentFormat({ name: "methods.pdf", type: "application/pdf" })).toBe("PDF") + expect(attachmentFormat({ name: "counts.tsv", type: "text/tab-separated-values" })).toBe("TSV") + expect(attachmentFormat({ name: "clipboard", type: "image/png" })).toBe("PNG") + }) }) diff --git a/frontend/workspace/src/components/prompt-attachment.ts b/frontend/workspace/src/components/prompt-attachment.ts index 678f2df8..f6f2a3e6 100644 --- a/frontend/workspace/src/components/prompt-attachment.ts +++ b/frontend/workspace/src/components/prompt-attachment.ts @@ -52,3 +52,18 @@ export function attachmentSize(bytes: number) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } + +export function attachmentFormat(file: { name: string; type: string }) { + const dot = file.name.lastIndexOf(".") + const extension = + dot > -1 + ? file.name + .slice(dot + 1) + .trim() + .toUpperCase() + : "" + if (extension && extension.length <= 8) return extension + if (file.type === "application/pdf") return "PDF" + const subtype = file.type.split("/").pop()?.replace(/^x-/, "").toUpperCase() + return subtype || "FILE" +} diff --git a/frontend/workspace/src/components/prompt-input-surface.test.ts b/frontend/workspace/src/components/prompt-input-surface.test.ts index 008c2eb3..d21e9962 100644 --- a/frontend/workspace/src/components/prompt-input-surface.test.ts +++ b/frontend/workspace/src/components/prompt-input-surface.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" const source = await Bun.file(new URL("./prompt-input.tsx", import.meta.url)).text() const css = await Bun.file(new URL("../styles/atlas.css", import.meta.url)).text() +const componentCss = await Bun.file(new URL("./prompt-input.css", import.meta.url)).text() +const chatCss = await Bun.file(new URL("./chat-surface.css", import.meta.url)).text() const popover = await Bun.file(new URL("./model-settings-popover.tsx", import.meta.url)).text() describe("floating prompt surface", () => { @@ -9,17 +11,68 @@ describe("floating prompt surface", () => { expect(source).toContain('"workspace-composer": true') expect(source).not.toContain("bg-surface-raised-stronger-non-alpha shadow-xs-border relative") expect(source).not.toContain('"rounded-[14px] overflow-clip focus-within:shadow-xs-border"') - expect(css).toContain("border-radius: var(--workspace-composer-radius)") - expect(css).toContain(".workspace-composer {\n min-height: 90px;") - expect(css).toContain("box-shadow: 0 12px 30px") + expect(css).not.toContain("\n.workspace-composer {") + expect(css).not.toContain("\n.workspace-composer__footer {") + expect(chatCss).toContain("width: min(100%, 740px)") + expect(componentCss).toMatch(/form\.workspace-composer\s*\{[^}]*min-height: 92px/s) + expect(componentCss).toContain("border-radius: var(--radius-xl)") + expect(componentCss).toContain("background: var(--color-surface-solid") + expect(componentCss).toContain("box-shadow: var(--atlas-shadow-xs)") }) test("keeps primary composer controls visible at the compact research scale", () => { expect(source).toContain('class="workspace-composer__attach') expect(source).toContain('class="workspace-composer__send') - expect(source).toContain('class="size-5"') - expect(source).toContain("text-[15px]") + expect(source).toContain('name="paperclip" class="size-4"') expect(source).toContain('icon={working() ? "stop" : "arrow-up"}') + expect(source).toContain('data-composer-action={working() ? "stop" : prompt.dirty() ? "send" : "idle"}') + expect(componentCss).toContain('.workspace-composer__send[data-composer-action="stop"]') + }) + + test("keeps placeholder, caret, and entered text on one responsive type geometry", () => { + expect(source).toContain("data-composer-mode={store.mode}") + expect(source).not.toContain('"font-mono!": store.mode === "shell"') + expect(source).not.toContain("text-[15px] leading-[1.45]") + expect(componentCss).toContain( + 'form.workspace-composer :is([data-component="prompt-input"], .workspace-composer__placeholder)', + ) + expect(componentCss).toContain("--composer-editor-font-size: var(--font-size-base)") + expect(componentCss).toContain("--composer-editor-line-height: 20px") + expect(componentCss).toContain("--composer-editor-font-weight: var(--font-weight-regular)") + expect(componentCss).toContain("padding: var(--composer-editor-padding-block-start)") + expect(componentCss).toContain("font-optical-sizing: auto") + expect(componentCss).toContain("font-synthesis: none") + expect(componentCss).toContain('.workspace-composer__editor[data-composer-mode="shell"]') + expect(componentCss).toContain("--composer-editor-font-family: var(--font-family-mono)") + expect(componentCss).toContain("position: absolute") + expect(componentCss).toContain("inset: 0 0 auto") + expect(source).toContain("aria-label={placeholder()}") + expect(source).toContain('aria-hidden="true" dir="auto"') + expect(componentCss).toContain("--composer-editor-font-size: 16px") + expect(componentCss).toContain("--composer-editor-line-height: 24px") + expect(componentCss).not.toMatch(/font-weight:\s*(?:400|450|500|550|600|700)/) + }) + + test("uses the shared icon system and accessible control groups instead of text glyphs", () => { + expect(source).toContain('role="group"') + expect(source).toContain('aria-label="Composer tools"') + expect(source).toContain('aria-label="Model and send"') + expect(source).toContain('<Icon name="flask" class="size-4" />') + expect(source).not.toContain('<Icon name="sliders" class="size-4" />') + expect(source).toContain('aria-label="Research capabilities"') + expect(source).toContain("aria-expanded={modeOpen()}") + expect(source).toContain('<Icon name="chevron-right" size="small" />') + expect(source).toContain('<Icon name="chevron-left" size="small" />') + expect(source).not.toContain(">›</span>") + expect(source).not.toContain('<span aria-hidden="true">‹</span>') + }) + + test("keeps compact desktop controls and explicit coarse-pointer targets", () => { + expect(componentCss).toContain("width: 34px") + expect(componentCss).toContain("height: 34px") + expect(componentCss).toContain("@media (pointer: coarse)") + expect(componentCss).toContain("min-width: 44px") + expect(componentCss).toContain("min-height: 44px") }) test("uses one geometry token for message and jump-to-latest clearance", () => { diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index 7dcac42e..45d9a8e5 100644 --- a/frontend/workspace/src/components/prompt-input.css +++ b/frontend/workspace/src/components/prompt-input.css @@ -6,7 +6,7 @@ gap: 12px; padding: 6px 8px 6px 12px; border: 1px solid var(--border-weak-base); - border-radius: 8px; + border-radius: var(--radius-xs); background: var(--surface-raised-stronger-non-alpha); color: var(--text-strong); } @@ -20,7 +20,7 @@ .workspace-composer__setup strong { font-size: 12px; - font-weight: 600; + font-weight: var(--font-weight-medium); line-height: 17px; } @@ -38,12 +38,12 @@ flex: 0 0 auto; padding-inline: 10px; border: 1px solid var(--border-weak-base); - border-radius: 6px; + border-radius: var(--radius-xs); background: var(--surface-base); color: var(--text-strong); font: inherit; font-size: 12px; - font-weight: 500; + font-weight: var(--font-weight-regular); cursor: pointer; } @@ -51,6 +51,57 @@ background: var(--surface-raised-base-hover); } +.workspace-composer__context { + gap: 6px; + padding: 8px 8px 0; +} + +.workspace-composer__context-item { + height: 46px; + gap: 2px; + padding: 5px 6px 5px 8px; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-xs); + background: var(--surface-raised-strong); + transition: + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard); +} + +.workspace-composer__context-item:hover { + border-color: var(--border-base); +} + +.workspace-composer__context-heading { + min-width: 0; + min-height: 20px; + color: var(--text-strong); + font-size: 12.5px; + font-weight: var(--font-weight-medium); + line-height: 16px; +} + +.workspace-composer__context-remove { + width: 24px; + height: 24px; + margin-right: -2px; + opacity: 0.58; +} + +.workspace-composer__context-remove:hover, +.workspace-composer__context-remove:focus-visible { + opacity: 1; +} + +.workspace-composer__context-comment { + margin-left: 20px; + padding-right: 4px; + color: var(--text-weak); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 15px; +} + .workspace-composer__attachments { display: flex; flex-wrap: wrap; @@ -58,25 +109,78 @@ padding: 10px 10px 0; } +.workspace-composer__dropzone { + border-radius: inherit; + background: var(--surface-raised-stronger-non-alpha); + backdrop-filter: blur(3px); +} + +.workspace-composer__dropzone-copy { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + column-gap: 9px; + color: var(--text-weak); +} + +.workspace-composer__dropzone-copy > [data-component="icon"] { + grid-row: 1 / span 2; +} + +.workspace-composer__dropzone-copy strong { + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 17px; +} + +.workspace-composer__dropzone-copy span { + color: var(--text-weak); + font-size: 11.5px; + font-weight: var(--font-weight-regular); + line-height: 15px; +} + .workspace-composer__attachment { min-width: 0; max-width: min(260px, 100%); - height: 42px; + height: 48px; display: grid; - grid-template-columns: 30px minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) 24px; align-items: center; gap: 7px; padding: 5px 5px 5px 6px; - border: 1px solid color-mix(in srgb, var(--border-weak-base) 82%, transparent); - border-radius: 9px; - background: color-mix(in srgb, var(--surface-raised-stronger-non-alpha) 74%, transparent); + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-sm); + background: var(--surface-raised-strong); +} + +.workspace-composer__attachment-open { + min-width: 0; + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + border-radius: var(--radius-xs); + color: inherit; + text-decoration: none; +} + +.workspace-composer__attachment-open:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.workspace-composer__attachment-open:hover .workspace-composer__attachment-copy strong { + text-decoration: underline; + text-underline-offset: 2px; } .workspace-composer__attachment-icon, .workspace-composer__attachment-preview { width: 30px; height: 30px; - border-radius: 6px; + border-radius: var(--radius-xs); } .workspace-composer__attachment-icon { @@ -89,7 +193,6 @@ .workspace-composer__attachment-preview { object-fit: cover; - cursor: zoom-in; } .workspace-composer__attachment-copy { @@ -108,14 +211,15 @@ .workspace-composer__attachment-copy strong { color: var(--text-strong); - font-size: 11.5px; - font-weight: 500; + font-size: 12.5px; + font-weight: var(--font-weight-medium); + line-height: 16px; } .workspace-composer__attachment-copy span { color: var(--text-weak); - font-size: 10px; - text-transform: lowercase; + font-size: 12px; + line-height: 15px; } .workspace-composer__attachment-remove { @@ -125,7 +229,7 @@ align-items: center; justify-content: center; border: 0; - border-radius: 6px; + border-radius: var(--radius-xs); background: transparent; cursor: pointer; opacity: 0.72; @@ -151,7 +255,7 @@ justify-content: center; padding: 0; border: 1px solid var(--border-weak-base); - border-radius: 10px; + border-radius: var(--radius-sm); background: var(--surface-raised-strong); color: var(--text-weak); cursor: pointer; @@ -174,9 +278,9 @@ } .workspace-composer__overflow > summary:focus-visible { - outline: 2px solid #4f8cff !important; + outline: 2px solid var(--color-focus); outline-offset: 1px; - box-shadow: none !important; + box-shadow: none; } .workspace-composer__overflow > div { @@ -189,9 +293,9 @@ overflow-y: auto; padding: 6px; border: 1px solid var(--border-base); - border-radius: 13px; + border-radius: var(--radius-md); background: var(--surface-raised-stronger-non-alpha); - box-shadow: 0 12px 30px color-mix(in srgb, #000 18%, transparent); + box-shadow: var(--atlas-shadow-md); } .workspace-composer__overflow button { @@ -203,12 +307,12 @@ gap: 16px; padding: 0 10px; border: 0; - border-radius: 8px; + border-radius: var(--radius-xs); background: transparent; color: var(--text-strong); font: inherit; font-size: 13.5px; - font-weight: 450; + font-weight: var(--font-weight-regular); line-height: 18px; text-align: left; white-space: nowrap; @@ -226,6 +330,36 @@ white-space: nowrap; } +.workspace-composer__capability-copy { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + white-space: normal; +} + +.workspace-composer__capability-copy > span { + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 16px; +} + +.workspace-composer__capability-copy small { + overflow: hidden; + color: var(--text-weaker); + font-size: 11px; + font-weight: var(--font-weight-regular); + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-composer__capability-list button:has(.workspace-composer__capability-copy) { + min-height: 50px; +} + .workspace-composer__capability-value { min-width: 0; max-width: 142px; @@ -235,18 +369,24 @@ flex: 0 0 auto; color: var(--text-weaker); font-size: 12.5px; - font-weight: 400; + font-weight: var(--font-weight-regular); text-overflow: ellipsis; white-space: nowrap; } .workspace-composer__capability-chevron { - display: inline-block; - margin-left: 5px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 4px; color: var(--text-weaker); - font-size: 17px; - line-height: 1; - transform: translateY(1px); +} + +.workspace-composer__capability-chevron [data-slot="icon-svg"] { + width: 14px; + height: 14px; } .workspace-composer__capability-switch { @@ -254,7 +394,7 @@ width: 34px; height: 20px; flex: 0 0 34px; - border-radius: 999px; + border-radius: var(--composer-radius-pill); background: var(--surface-raised-base-active); box-shadow: inset 0 0 0 1px var(--border-base); transition: background 140ms ease; @@ -266,14 +406,14 @@ left: 3px; width: 14px; height: 14px; - border-radius: 999px; + border-radius: var(--composer-radius-pill); background: var(--text-strong); - box-shadow: 0 1px 3px color-mix(in srgb, #000 26%, transparent); + box-shadow: var(--atlas-shadow-xs); transition: transform 140ms ease; } .workspace-composer__capability-switch[data-checked="true"] { - background: #4f8cff; + background: var(--surface-brand-base); box-shadow: none; } @@ -301,12 +441,12 @@ display: block; padding: 5px 10px; color: var(--text-weaker); - font-size: 11.5px; + font-size: 12px; line-height: 1.35; } .workspace-composer__compute-label { - font-weight: 550; + font-weight: var(--font-weight-medium); } .workspace-composer__compute-hint { @@ -339,7 +479,7 @@ .workspace-composer__specialist-back strong { font-size: 13.5px; - font-weight: 550; + font-weight: var(--font-weight-medium); } .workspace-composer__specialist-list .workspace-composer__specialist-option { @@ -365,16 +505,16 @@ .workspace-composer__specialist-option strong { color: var(--text-strong); font-size: 13px; - font-weight: 520; + font-weight: var(--font-weight-medium); } .workspace-composer__specialist-option small { display: -webkit-box; overflow: hidden; color: var(--text-weaker); - font-size: 10.5px; - font-weight: 400; - line-height: 1.3; + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1.35; -webkit-box-orient: vertical; text-overflow: ellipsis; white-space: nowrap; @@ -383,11 +523,7 @@ .workspace-composer__specialist-option [data-slot="icon-svg"] { flex: 0 0 auto; - color: #4f8cff; -} - -.workspace-composer__reviewer-more { - min-height: 40px !important; + color: var(--text-interactive-base); } .workspace-composer__reviewer-more small { @@ -439,3 +575,333 @@ white-space: normal; } } + +/* Composer surface + ---------------- + This file is the sole owner of the input surface, editor baseline, utility + row, and responsive control geometry. */ + +form.workspace-composer { + --composer-radius-pill: 999px; + --composer-editor-font-family: var(--font-family-sans); + --composer-editor-font-features: var(--font-family-sans--font-feature-settings, normal); + --composer-editor-font-variation: var(--font-family-sans--font-variation-settings, normal); + /* Keep the editor on the same type rhythm as the surrounding workspace. + Mobile still steps up to 16px below to prevent browser zoom. */ + --composer-editor-font-size: var(--font-size-base); + --composer-editor-font-weight: var(--font-weight-regular); + --composer-editor-line-height: 20px; + --composer-editor-padding-block-start: 13px; + --composer-editor-padding-block-end: 8px; + --composer-editor-padding-inline: 15px; + min-height: 92px; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-xl); + background: var(--color-surface-solid, var(--surface-raised-stronger-non-alpha)); + box-shadow: var(--atlas-shadow-xs); + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +form.workspace-composer:focus-within { + border-color: var(--focus-lit-ring); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--focus-lit-ring) 13%, transparent), + var(--atlas-shadow-xs); +} + +.workspace-composer__editor { + position: relative; + max-height: 240px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-base) transparent; +} + +.workspace-composer__editor[data-composer-mode="shell"] { + --composer-editor-font-family: var(--font-family-mono); + --composer-editor-font-features: var(--font-family-mono--font-feature-settings, normal); + --composer-editor-font-variation: var(--font-family-mono--font-variation-settings, normal); +} + +/* One declaration owns every metric that affects the first glyph's baseline. + Keeping the placeholder and contenteditable in the same selector prevents + utility classes or future component changes from drifting apart. */ +form.workspace-composer :is([data-component="prompt-input"], .workspace-composer__placeholder) { + box-sizing: border-box; + width: 100%; + min-height: 49px; + padding: var(--composer-editor-padding-block-start) var(--composer-editor-padding-inline) + var(--composer-editor-padding-block-end); + font-family: var(--composer-editor-font-family); + font-feature-settings: var(--composer-editor-font-features); + font-variation-settings: var(--composer-editor-font-variation); + font-kerning: normal; + font-optical-sizing: auto; + font-size: var(--composer-editor-font-size); + font-style: normal; + font-weight: var(--composer-editor-font-weight); + line-height: var(--composer-editor-line-height); + letter-spacing: 0; + font-synthesis: none; +} + +form.workspace-composer [data-component="prompt-input"] { + display: block; + color: var(--text-strong); + caret-color: var(--text-strong); + overflow-wrap: anywhere; + text-align: start; + white-space: pre-wrap; +} + +form.workspace-composer .workspace-composer__placeholder { + position: absolute; + inset: 0 0 auto; + overflow: hidden; + color: var(--text-weak); + pointer-events: none; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +form.workspace-composer .workspace-composer__footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 4px 8px 8px; +} + +form.workspace-composer .workspace-composer__controls, +form.workspace-composer .workspace-composer__actions { + min-width: 0; + gap: 4px; +} + +form.workspace-composer .workspace-composer__controls { + overflow: visible; +} + +form.workspace-composer .workspace-composer__actions { + justify-content: flex-end; +} + +form.workspace-composer .workspace-composer__attach { + width: 32px; + height: 32px; + min-height: 32px; + justify-content: center; + padding: 0; + border-radius: var(--radius-xs); + color: var(--text-weak); + font-weight: var(--font-weight-regular); +} + +form.workspace-composer .workspace-composer__attach:hover:not(:disabled), +form.workspace-composer .workspace-composer__attach:focus-visible { + background: var(--color-bg-subtle, var(--surface-raised-base-hover)); + color: var(--text-strong); +} + +form.workspace-composer .workspace-composer__overflow > summary { + width: auto; + min-width: 32px; + height: 32px; + min-height: 32px; + gap: 6px; + padding: 0 8px 0 7px; + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text-weak); + font-weight: var(--font-weight-regular); +} + +form.workspace-composer .workspace-composer__overflow > summary:hover, +form.workspace-composer .workspace-composer__overflow[open] > summary { + background: var(--color-bg-subtle, var(--surface-raised-base-hover)); + color: var(--text-strong); +} + +.workspace-composer__overflow-label { + color: currentColor; + font-family: var(--font-family-sans); + font-size: 12.5px; + font-weight: var(--font-weight-regular); + line-height: 1; + white-space: nowrap; +} + +form.workspace-composer .workspace-composer__overflow > div { + padding: 5px; + border-color: var(--color-border, var(--border-base)); + border-radius: var(--radius-sm); + background: var(--color-surface-solid, var(--surface-raised-stronger-non-alpha)); + box-shadow: var(--atlas-shadow-md); +} + +form.workspace-composer .workspace-composer__overflow button { + min-height: 36px; + padding: 0 9px; + border-radius: var(--radius-xs); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 18px; +} + +form.workspace-composer .workspace-composer__capability-list button:has(.workspace-composer__capability-copy) { + min-height: 50px; +} + +form.workspace-composer .workspace-composer__specialist-list .workspace-composer__specialist-option { + min-height: 46px; +} + +form.workspace-composer .workspace-composer__specialist-list .workspace-composer__reviewer-more { + min-height: 40px; +} + +form.workspace-composer .workspace-composer__specialist-list .workspace-composer__specialist-back { + min-height: 36px; +} + +.workspace-composer__specialist-back > [data-component="icon"] { + color: var(--text-weaker); +} + +form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + min-height: 32px; + max-width: min(220px, 32vw); + padding: 0 7px; + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-regular); +} + +form.workspace-composer + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]:hover, +form.workspace-composer + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"]:focus-visible, +form.workspace-composer + [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"][data-expanded] { + background: var(--color-bg-subtle, var(--surface-raised-base-hover)); +} + +form.workspace-composer .workspace-composer__send { + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + border-radius: var(--composer-radius-pill); + box-shadow: none; +} + +form.workspace-composer .workspace-composer__send[data-composer-action="idle"]:disabled { + background-color: var(--color-bg-subtle, var(--surface-raised-base-hover)); +} + +form.workspace-composer .workspace-composer__send[data-composer-action="idle"]:disabled [data-slot="icon-svg"] { + color: var(--text-weaker); +} + +form.workspace-composer .workspace-composer__send[data-composer-action="stop"] { + background-color: var(--surface-critical-strong); +} + +form.workspace-composer .workspace-composer__send[data-composer-action="stop"]:hover:not(:disabled), +form.workspace-composer .workspace-composer__send[data-composer-action="stop"]:focus-visible { + background-color: var(--icon-critical-hover); +} + +form.workspace-composer .workspace-composer__send[data-composer-action="stop"] [data-slot="icon-svg"] { + color: var(--icon-invert-base); +} + +/* The conversation column can narrow independently of the viewport when the + research pane is resized. Mirror the small-screen control geometry against + that actual available width instead of waiting for a window breakpoint. */ +@container conversation (max-width: 540px) { + form.workspace-composer { + --composer-editor-padding-inline: 12px; + } + + form.workspace-composer .workspace-composer__footer { + gap: 5px; + padding: 5px 6px 6px; + } + + form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + max-width: min(148px, 38cqi); + } +} + +@container conversation (max-width: 390px) { + form.workspace-composer .workspace-composer__overflow > summary { + width: 32px; + padding: 0; + } + + .workspace-composer__overflow-label { + display: none; + } + + form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + max-width: min(124px, 40cqi); + } +} + +@media (max-width: 719px) { + form.workspace-composer { + --composer-editor-font-size: 16px; + --composer-editor-line-height: 24px; + --composer-editor-padding-inline: 12px; + min-height: 96px; + border-radius: var(--radius-xl); + } + + form.workspace-composer .workspace-composer__footer { + gap: 5px; + padding: 5px 6px 6px; + } + + form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + max-width: min(148px, 38vw); + } +} + +@media (max-width: 390px) { + form.workspace-composer .workspace-composer__overflow > summary { + width: 32px; + padding: 0; + } + + .workspace-composer__overflow-label { + display: none; + } + + form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + max-width: 124px; + } +} + +@media (pointer: coarse) { + form.workspace-composer .workspace-composer__attach, + form.workspace-composer .workspace-composer__overflow > summary, + form.workspace-composer .workspace-composer__send, + form.workspace-composer [data-component="button"][data-variant="ghost"][data-model-settings-trigger-style="label"] { + min-width: 44px; + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + form.workspace-composer { + transition: none; + } +} diff --git a/frontend/workspace/src/components/prompt-input.tsx b/frontend/workspace/src/components/prompt-input.tsx index cae64f78..161c2835 100644 --- a/frontend/workspace/src/components/prompt-input.tsx +++ b/frontend/workspace/src/components/prompt-input.tsx @@ -51,13 +51,19 @@ import { Binary } from "@synsci/util/binary" import { showToast } from "@synsci/ui/toast" import { uiStore } from "@/atlas/store/ui" import { projectHref, projectPathname } from "@/utils/project-route" -import { canonicalKey, displayProviderForModel, modelSummary } from "@/context/model-catalog" -import { RECOMMENDED_MODELS } from "@/context/models" +import { displayProviderForModel, modelSummary } from "@/context/model-catalog" import { DialogSelectModel } from "./dialog-select-model" import { ModelSettingsPopover } from "./model-settings-popover" import { DialogSettings } from "./dialog-settings" import "./prompt-input.css" -import { ATTACHMENT_ACCEPT, MAX_ATTACHMENT_BYTES, attachmentMime, attachmentSize } from "./prompt-attachment" +import { + ATTACHMENT_ACCEPT, + MAX_ATTACHMENT_BYTES, + attachmentFormat, + attachmentMime, + attachmentSize, +} from "./prompt-attachment" +import { curateQuickModels } from "./model-quick" import { settingsApi } from "./settings/api" import { delegatedSpecialist, @@ -87,34 +93,6 @@ type ComputePreference = { providers: Array<{ id: string; connected: boolean; enabled: boolean }> } -const EXAMPLES = [ - "prompt.example.1", - "prompt.example.2", - "prompt.example.3", - "prompt.example.4", - "prompt.example.5", - "prompt.example.6", - "prompt.example.7", - "prompt.example.8", - "prompt.example.9", - "prompt.example.10", - "prompt.example.11", - "prompt.example.12", - "prompt.example.13", - "prompt.example.14", - "prompt.example.15", - "prompt.example.16", - "prompt.example.17", - "prompt.example.18", - "prompt.example.19", - "prompt.example.20", - "prompt.example.21", - "prompt.example.22", - "prompt.example.23", - "prompt.example.24", - "prompt.example.25", -] as const - interface SlashCommand { id: string trigger: string @@ -179,25 +157,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const setModal = (value: { connected: boolean; enabled: boolean }) => setCap("modal", value) const reviewModels = createMemo(() => { - const recommendations = RECOMMENDED_MODELS.map((item) => { - const key = canonicalKey(item.providerID, item.modelID) - return local.model.list().find((model) => canonicalKey(model.provider.id, model.id) === key) - }).filter((model): model is NonNullable<typeof model> => Boolean(model)) - const options = [ - ...local.model.pinned(), - ...recommendations, - local.model.current(), - ...local.model.recent(), - ].filter((model): model is NonNullable<typeof model> => Boolean(model)) - const seen = new Set<string>() - return options - .filter((model) => { - const key = canonicalKey(model.provider.id, model.id) - if (seen.has(key)) return false - seen.add(key) - return true - }) - .slice(0, 3) + const pinned = local.model.pinned().filter((model): model is NonNullable<typeof model> => Boolean(model)) + const recent = local.model.recent().filter((model): model is NonNullable<typeof model> => Boolean(model)) + const available = local.model + .list() + .filter( + (model) => + local.model.pin.has({ providerID: model.provider.id, modelID: model.id }) || + local.model.visible({ providerID: model.provider.id, modelID: model.id }), + ) + return curateQuickModels({ pinned, current: local.model.current(), recent, available, limit: 3 }) }) const reviewerLabel = createMemo(() => { @@ -476,7 +445,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { popover: "at" | "slash" | null historyIndex: number savedPrompt: Prompt | null - placeholder: number dragging: boolean mode: "normal" | "shell" applyingHistory: boolean @@ -484,12 +452,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { popover: null, historyIndex: -1, savedPrompt: null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), dragging: false, mode: "normal", applyingHistory: false, }) + const placeholder = createMemo(() => { + if (store.mode === "shell") return language.t("prompt.placeholder.shell") + if (commentCount() > 1) return language.t("prompt.placeholder.summarizeComments") + if (commentCount() === 1) return language.t("prompt.placeholder.summarizeComment") + return language.t("prompt.placeholder.normal") + }) + const MAX_HISTORY = 100 const [history, setHistory] = persisted( Persist.global("prompt-history", ["prompt-history.v1"]), @@ -553,15 +527,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) - createEffect(() => { - params.id - if (params.id) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 @@ -570,7 +535,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!mime) { showToast({ variant: "error", - title: "file not attached", + title: "File not attached", description: `${file.name} is not a supported image, PDF, text, code, or scientific data file.`, }) return @@ -578,7 +543,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (file.size > MAX_ATTACHMENT_BYTES) { showToast({ variant: "error", - title: "file not attached", + title: "File not attached", description: `${file.name} is ${attachmentSize(file.size)}; attachments are limited to ${attachmentSize(MAX_ATTACHMENT_BYTES)}.`, }) return @@ -594,7 +559,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { (error: unknown) => { showToast({ variant: "error", - title: "file not attached", + title: "File not attached", description: error instanceof Error ? error.message : String(error), }) return undefined @@ -2079,15 +2044,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }} > <Show when={store.dragging}> - <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> - <div class="flex flex-col items-center gap-2 text-text-weak"> - <Icon name="photo" class="size-8" /> - <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span> + <div class="workspace-composer__dropzone absolute inset-0 z-10 flex items-center justify-center pointer-events-none"> + <div class="workspace-composer__dropzone-copy"> + <Icon name="paperclip" class="size-6" /> + <strong>{language.t("prompt.dropzone.label")}</strong> + <span>{language.t("prompt.dropzone.hint")}</span> </div> </div> </Show> <Show when={prompt.context.items().length > 0}> - <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> + <div class="workspace-composer__context flex flex-nowrap items-start gap-2 overflow-x-auto no-scrollbar"> <For each={prompt.context.items()}> {(item) => { const active = () => { @@ -2109,19 +2075,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { > <div classList={{ - "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, + "workspace-composer__context-item group shrink-0 flex flex-col max-w-[220px]": true, "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(), - "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": - active(), - "bg-background-stronger": !active(), + "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover": active(), }} onClick={() => { openComment(item) }} > - <div class="flex items-center gap-1.5"> + <div class="workspace-composer__context-heading flex items-center gap-1.5"> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> - <div class="flex items-center text-11-regular min-w-0"> + <div class="flex items-center min-w-0"> <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> <Show when={item.selection}> {(sel) => ( @@ -2137,7 +2101,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" + class="workspace-composer__context-remove ml-auto" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -2147,9 +2111,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { /> </div> <Show when={item.comment}> - {(comment) => ( - <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div> - )} + {(comment) => <div class="workspace-composer__context-comment truncate">{comment()}</div>} </Show> </div> </Tooltip> @@ -2162,31 +2124,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <div class="workspace-composer__attachments" aria-label="Attached files"> <For each={imageAttachments()}> {(attachment) => ( - <div class="workspace-composer__attachment" data-image={attachment.mime.startsWith("image/")}> - <Show - when={attachment.mime.startsWith("image/")} - fallback={ - <div class="workspace-composer__attachment-icon" aria-hidden="true"> - <Icon name="folder" class="size-4" /> - </div> - } + <div + class="workspace-composer__attachment" + data-image={attachment.mime.startsWith("image/")} + data-attachment-status="attached" + > + <a + href={attachment.dataUrl} + target="_blank" + rel="noreferrer" + class="workspace-composer__attachment-open" + aria-label={`${attachment.mime.startsWith("image/") ? "Preview" : "Open"} ${attachment.filename}`} + onClick={(event) => { + if (!attachment.mime.startsWith("image/")) return + event.preventDefault() + dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) + }} > - <img - src={attachment.dataUrl} - alt={attachment.filename} - class="workspace-composer__attachment-preview" - onClick={() => - dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) + <Show + when={attachment.mime.startsWith("image/")} + fallback={ + <div class="workspace-composer__attachment-icon" aria-hidden="true"> + <FileIcon node={{ path: attachment.filename, type: "file" }} class="size-4" /> + </div> } - /> - </Show> - <div class="workspace-composer__attachment-copy"> - <strong title={attachment.filename}>{attachment.filename}</strong> - <span> - {attachment.mime.split("/").pop()?.replace("x-", "") ?? "file"} - <Show when={attachment.size !== undefined}> · {attachmentSize(attachment.size!)}</Show> + > + <img src={attachment.dataUrl} alt="" class="workspace-composer__attachment-preview" /> + </Show> + <span class="workspace-composer__attachment-copy"> + <strong title={attachment.filename}>{attachment.filename}</strong> + <span> + Attached · {attachmentFormat({ name: attachment.filename, type: attachment.mime })} + <Show when={attachment.size !== undefined}> · {attachmentSize(attachment.size!)}</Show> + </span> </span> - </div> + </a> <button type="button" onClick={() => removeImageAttachment(attachment.id)} @@ -2200,7 +2172,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </For> </div> </Show> - <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}> + <div class="workspace-composer__editor" data-composer-mode={store.mode} ref={(el) => (scrollRef = el)}> <div data-component="prompt-input" ref={(el) => { @@ -2209,15 +2181,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }} role="textbox" aria-multiline="true" - aria-label={ - store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) - } + aria-label={placeholder()} + dir="auto" contenteditable="true" onInput={handleInput} onPaste={handlePaste} @@ -2226,34 +2191,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full p-3 pb-2.5 pr-14 text-[15px] leading-[1.45] text-text-strong focus:outline-none whitespace-pre-wrap": true, + "focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", }} /> <Show when={!prompt.dirty()}> - <div class="workspace-composer__placeholder absolute top-0 inset-x-0 text-[15px] leading-[1.45] text-text-weak pointer-events-none whitespace-nowrap truncate"> - {store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })} + <div class="workspace-composer__placeholder" aria-hidden="true" dir="auto"> + {placeholder()} </div> </Show> </div> <div class="workspace-composer__footer"> - <div data-slot="prompt-controls" class="workspace-composer__controls flex items-center justify-start gap-2"> + <div + data-slot="prompt-controls" + class="workspace-composer__controls flex items-center justify-start gap-2" + role="group" + aria-label="Composer tools" + > <input ref={fileInputRef} type="file" accept={ATTACHMENT_ACCEPT} + multiple class="hidden" onChange={(e) => { - const file = e.currentTarget.files?.[0] - if (file) void addAttachment(file) + const selected = Array.from(e.currentTarget.files ?? []) + for (const file of selected) void addAttachment(file) e.currentTarget.value = "" }} /> @@ -2270,11 +2234,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Button type="button" variant="ghost" - class="workspace-composer__attach size-9 shrink-0" + class="workspace-composer__attach shrink-0" onClick={attach} aria-label={language.t("prompt.action.attachFile")} > - <Icon name="plus" class="size-5" /> + <Icon name="paperclip" class="size-4" /> </Button> </Tooltip> <details @@ -2300,7 +2264,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { aria-expanded={modeOpen()} title="Research capabilities" > - <Icon name="sliders" /> + <Icon name="flask" class="size-4" /> + <span class="workspace-composer__overflow-label">Research tools</span> </summary> <div role="menu" aria-label="Research capabilities"> <Switch> @@ -2313,7 +2278,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { disabled={capabilityBusy()} onClick={toggleDelegation} > - <span>Delegation</span> + <span class="workspace-composer__capability-copy"> + <span>Delegation</span> + <small>Assign focused work to a best-fit specialist.</small> + </span> <span aria-hidden="true" class="workspace-composer__capability-switch" @@ -2329,7 +2297,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { disabled={capabilityBusy()} onClick={toggleReview} > - <span>Auto-review</span> + <span class="workspace-composer__capability-copy"> + <span>Auto-review</span> + <small>Check completed work before it is returned.</small> + </span> <span aria-hidden="true" class="workspace-composer__capability-switch" @@ -2339,17 +2310,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </span> </button> <button type="button" role="menuitem" onClick={() => setCapabilityView("reviewer")}> - <span>Reviewer model</span> + <span class="workspace-composer__capability-copy"> + <span>Reviewer model</span> + <small>Choose which model performs the review pass.</small> + </span> <span class="workspace-composer__capability-value"> - {reviewerLabel()} <span class="workspace-composer__capability-chevron">›</span> + {reviewerLabel()} + <span class="workspace-composer__capability-chevron"> + <Icon name="chevron-right" size="small" /> + </span> </span> </button> <div role="separator" class="workspace-composer__capability-divider" /> <button type="button" role="menuitem" onClick={() => setCapabilityView("specialists")}> - <span>Specialist</span> + <span class="workspace-composer__capability-copy"> + <span>Specialist</span> + <small>Set the default research domain.</small> + </span> <span class="workspace-composer__capability-value"> {specialist() ? specialistLabel(specialist()!) : "Research"} - <span class="workspace-composer__capability-chevron">›</span> + <span class="workspace-composer__capability-chevron"> + <Icon name="chevron-right" size="small" /> + </span> </span> </button> <button @@ -2364,7 +2346,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <span>Compute</span> <span class="workspace-composer__capability-value"> {modal().enabled ? "Modal" : "Local"} - <span class="workspace-composer__capability-chevron">›</span> + <span class="workspace-composer__capability-chevron"> + <Icon name="chevron-right" size="small" /> + </span> </span> </button> </div> @@ -2377,7 +2361,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { class="workspace-composer__specialist-back" onClick={() => setCapabilityView("main")} > - <span aria-hidden="true">‹</span> + <Icon name="chevron-left" size="small" /> <strong>Specialist</strong> </button> <div role="separator" class="workspace-composer__capability-divider" /> @@ -2429,7 +2413,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { class="workspace-composer__specialist-back" onClick={() => setCapabilityView("main")} > - <span aria-hidden="true">‹</span> + <Icon name="chevron-left" size="small" /> <strong>Reviewer model</strong> </button> <div role="separator" class="workspace-composer__capability-divider" /> @@ -2492,7 +2476,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <strong>More models</strong> <small>Browse the full model catalog.</small> </span> - <span class="workspace-composer__capability-chevron">›</span> + <span class="workspace-composer__capability-chevron"> + <Icon name="chevron-right" size="small" /> + </span> </button> </div> </Match> @@ -2531,7 +2517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </Match> </Switch> </div> - <div class="workspace-composer__actions flex items-center gap-3"> + <div class="workspace-composer__actions flex items-center gap-3" role="group" aria-label="Model and send"> <ModelSettingsPopover /> <Tooltip placement="top" @@ -2558,7 +2544,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="workspace-composer__send size-10 rounded-full" + class="workspace-composer__send rounded-full" + data-composer-action={working() ? "stop" : prompt.dirty() ? "send" : "idle"} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> </Tooltip> diff --git a/frontend/workspace/src/components/prompt-placeholder.test.ts b/frontend/workspace/src/components/prompt-placeholder.test.ts new file mode 100644 index 00000000..89356484 --- /dev/null +++ b/frontend/workspace/src/components/prompt-placeholder.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { dict as ar } from "@/i18n/ar" +import { dict as br } from "@/i18n/br" +import { dict as da } from "@/i18n/da" +import { dict as de } from "@/i18n/de" +import { dict as en } from "@/i18n/en" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as ja } from "@/i18n/ja" +import { dict as ko } from "@/i18n/ko" +import { dict as no } from "@/i18n/no" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as th } from "@/i18n/th" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" + +const source = await Bun.file(new URL("./prompt-input.tsx", import.meta.url)).text() +const locales = { ar, br, da, de, en, es, fr, ja, ko, no, pl, ru, th, zh, zht } + +describe("research composer placeholder", () => { + test("uses one stable research-task prompt instead of rotating software examples", () => { + expect(source).not.toContain("const EXAMPLES") + expect(source).not.toContain("prompt.example.") + expect(source).not.toContain('setStore("placeholder"') + expect(source).not.toContain("Math.floor(Math.random()") + expect(source).toContain('return language.t("prompt.placeholder.normal")') + expect(en["prompt.placeholder.normal"]).toBe("Describe the research task you want to work through…") + }) + + test("keeps every locale deterministic and free of example interpolation", () => { + for (const dict of Object.values(locales)) { + const placeholder = dict["prompt.placeholder.normal"] + expect(placeholder).not.toContain("{{example}}") + expect(placeholder).toEndWith("…") + expect(Object.keys(dict).some((key) => key.startsWith("prompt.example."))).toBe(false) + } + }) + + test("derives the visible and accessible labels from the same mode-aware value", () => { + expect(source).toContain('if (store.mode === "shell") return language.t("prompt.placeholder.shell")') + expect(source).toContain('if (commentCount() > 1) return language.t("prompt.placeholder.summarizeComments")') + expect(source).toContain('if (commentCount() === 1) return language.t("prompt.placeholder.summarizeComment")') + expect(source).toContain("aria-label={placeholder()}") + expect(source).toContain('class="workspace-composer__placeholder" aria-hidden="true" dir="auto"') + }) +}) diff --git a/frontend/workspace/src/components/session/research-launchpad.test.ts b/frontend/workspace/src/components/session/research-launchpad.test.ts deleted file mode 100644 index d73d008e..00000000 --- a/frontend/workspace/src/components/session/research-launchpad.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" -import { fileURLToPath } from "node:url" -import type { JSX } from "solid-js" -import { createServer } from "vite" -import solid from "vite-plugin-solid" -import { researchSuggestions, researchWorkflows, workflowGroups, workflowPrompt } from "./research-launchpad" - -const view = () => readFileSync(fileURLToPath(new URL("./session-new-view.tsx", import.meta.url)), "utf8") -const server = await createServer({ - root: fileURLToPath(new URL("../../..", import.meta.url)), - mode: "production", - logLevel: "silent", - plugins: [solid({ ssr: false, dev: false })], - server: { middlewareMode: true }, - appType: "custom", - resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, - ssr: { - noExternal: true, - resolve: { conditions: ["browser", "production"] }, - }, -}) -const [subject, web] = await Promise.all([ - server.ssrLoadModule("/src/components/session/session-new-view.tsx") as Promise<typeof import("./session-new-view")>, - server.ssrLoadModule("solid-js/web") as Promise<typeof import("solid-js/web")>, -]) -const cleanups: Array<() => void> = [] - -afterAll(() => server.close()) - -afterEach(() => { - cleanups.splice(0).forEach((cleanup) => cleanup()) - document.body.replaceChildren() -}) - -const mount = (view: () => JSX.Element) => { - const host = document.createElement("div") - document.body.append(host) - cleanups.push(web.render(view, host)) - return host -} - -describe("research launchpad", () => { - test("ships launch-ready workflows across the core scientific loop", () => { - expect(researchWorkflows.map((workflow) => workflow.id)).toEqual([ - "analyze-data", - "single-cell", - "differential-expression", - "inspect-structure", - "sequence-qc", - "variant-analysis", - "assay-analysis", - "image-analysis", - "proteomics", - "run-notebook", - "protein-design", - "molecular-docking", - "molecular-dynamics", - "train-model", - "run-pipeline", - "survey-literature", - "clinical-trials", - "target-prioritization", - "reproduce-result", - "compare-runs", - "verify-citations", - "build-figure", - "write-report", - ]) - expect(new Set(researchWorkflows.map((workflow) => workflow.group))).toEqual( - new Set(["analyze", "compute", "discover", "communicate"]), - ) - }) - - test("groups workflows without losing their authored order", () => { - expect(workflowGroups().map((group) => group.id)).toEqual(["analyze", "compute", "discover", "communicate"]) - expect( - workflowGroups() - .find((group) => group.id === "analyze") - ?.workflows.map((workflow) => workflow.id), - ).toEqual([ - "analyze-data", - "single-cell", - "differential-expression", - "inspect-structure", - "sequence-qc", - "variant-analysis", - "assay-analysis", - "image-analysis", - "proteomics", - ]) - }) - - test("adds project context to workflow prompts when artifacts are available", () => { - const workflow = researchWorkflows[0] - expect(workflowPrompt(workflow, 0)).toBe(workflow.prompt) - expect(workflowPrompt(workflow, 12)).toContain("12 research artifacts") - expect(workflowPrompt(workflow, 12)).toContain(workflow.prompt) - }) - - test("keeps the default suggestions quiet and decision-relevant", () => { - expect(researchSuggestions.map((workflow) => workflow.id)).toEqual([ - "analyze-data", - "run-notebook", - "survey-literature", - ]) - }) - - test("keeps the default session empty instead of presenting a landing page", () => { - const source = view() - - expect(source).toContain('aria-label="New research session"') - expect(source).not.toContain("catalogOpen") - expect(source).not.toContain("researchWorkflows") - }) - - test("mounts a genuinely blank new-session canvas", () => { - expect(subject.NewSessionView).toBeFunction() - - const host = mount(() => subject.NewSessionView()) - const canvas = host.querySelector('[data-component="research-launchpad"]') - expect(canvas?.getAttribute("aria-label")).toBe("New research session") - expect(host.querySelectorAll("button")).toHaveLength(0) - expect(host.querySelectorAll("h1, h2, h3")).toHaveLength(0) - expect(host.textContent).toBe("") - }) -}) diff --git a/frontend/workspace/src/components/session/research-launchpad.ts b/frontend/workspace/src/components/session/research-launchpad.ts deleted file mode 100644 index 04b901e0..00000000 --- a/frontend/workspace/src/components/session/research-launchpad.ts +++ /dev/null @@ -1,275 +0,0 @@ -export type ResearchWorkflow = { - id: string - group: "analyze" | "compute" | "discover" | "communicate" - title: string - description: string - prompt: string - shortcut: string - icon: - | "table" - | "notebook" - | "atom" - | "sequence" - | "search" - | "reproduce" - | "compare" - | "report" - | "activity" - | "network" -} - -export const researchWorkflows: ResearchWorkflow[] = [ - { - id: "analyze-data", - group: "analyze", - title: "Analyze a dataset", - description: "Profile columns, find quality issues, visualize patterns, and produce a defensible result.", - prompt: - "Analyze the relevant dataset in this project. Inspect its schema and quality first, then compute useful summaries and visualizations. Explain every assumption and save any reusable analysis as a notebook.", - shortcut: "CSV · TSV · JSONL · HDF5", - icon: "table", - }, - { - id: "single-cell", - group: "analyze", - title: "Single-cell analysis", - description: "QC, normalize, cluster, annotate, and compare cells with traceable decisions.", - prompt: - "Run a defensible single-cell analysis on the relevant AnnData, Loom, 10x, or count-matrix data. Inspect QC before filtering, preserve raw counts, document thresholds, normalize and reduce dimensions, cluster, annotate with evidence, test differential expression, and save the executable notebook plus figures.", - shortcut: "Scanpy · scVI · CellTypist", - icon: "table", - }, - { - id: "differential-expression", - group: "analyze", - title: "Differential expression", - description: "Design a statistically valid comparison with diagnostics and pathway interpretation.", - prompt: - "Analyze differential expression for the relevant count or expression data. Recover the experimental design and covariates first, check sample QC and confounding, choose an appropriate DESeq2, edgeR, limma, or nonparametric workflow, correct multiple tests, produce diagnostic plots, and separate robust signals from exploratory ones.", - shortcut: "DESeq2 · edgeR · limma · GSEA", - icon: "compare", - }, - { - id: "inspect-structure", - group: "analyze", - title: "Inspect a structure", - description: "Render proteins or molecules, check chemistry, and investigate structural features.", - prompt: - "Inspect the relevant molecular or protein structure in this project. Render it, identify important chains, ligands, residues, or conformers, and explain any structural quality issues before drawing conclusions.", - shortcut: "PDB · CIF · SDF · MOL", - icon: "atom", - }, - { - id: "sequence-qc", - group: "analyze", - title: "Quality-check sequences", - description: "Review reads, variants, intervals, alignments, and per-cycle quality.", - prompt: - "Quality-check the relevant sequencing or genomics files in this project. Report read and base counts, quality and GC patterns, sample or contig coverage, and any concrete anomalies worth investigating.", - shortcut: "FASTQ · VCF · BAM · GFF", - icon: "sequence", - }, - { - id: "variant-analysis", - group: "analyze", - title: "Analyze variants", - description: "Inspect VCF/BAM evidence, annotate variants, and prioritize interpretable candidates.", - prompt: - "Analyze the relevant variant and alignment files. Validate references and sample metadata, inspect VCF and BAM quality, normalize and annotate variants with appropriate population and clinical databases, flag filtering assumptions, prioritize candidates, and produce a table whose evidence and provenance can be audited.", - shortcut: "VCF · BAM · ClinVar · gnomAD", - icon: "sequence", - }, - { - id: "assay-analysis", - group: "analyze", - title: "Analyze an assay", - description: "Normalize plates, fit dose response, quantify uncertainty, and surface QC failures.", - prompt: - "Analyze the relevant assay or plate data. Identify controls and layout, check edge and batch effects, normalize with a justified method, fit dose-response or IC50 curves when appropriate, quantify uncertainty and fit quality, flag failed wells or plates, and export a clean result table and publication-ready plots.", - shortcut: "plates · dose response · IC50", - icon: "table", - }, - { - id: "image-analysis", - group: "analyze", - title: "Analyze microscopy", - description: "Inspect image metadata, segment objects, extract features, and verify overlays.", - prompt: - "Build a reproducible microscopy analysis for the relevant images. Inspect channels, bit depth, scale, and acquisition metadata first; choose an appropriate Cellpose, StarDist, MONAI, or classical segmentation path; generate QC overlays; extract object-level features; and save masks, tables, figures, and parameters.", - shortcut: "Cellpose · StarDist · MONAI", - icon: "reproduce", - }, - { - id: "proteomics", - group: "analyze", - title: "Analyze proteomics", - description: "QC spectra and quantification, handle missingness, and test protein-level changes.", - prompt: - "Analyze the relevant mass-spectrometry or proteomics data. Inspect mzML or result-table quality, identify the acquisition and quantification method, check contaminants and missingness, normalize without leaking groups, perform protein-level statistics with multiple-testing correction, and save diagnostic figures and traceable result tables.", - shortcut: "mzML · OpenMS · DIA-NN", - icon: "activity", - }, - { - id: "run-notebook", - group: "compute", - title: "Run a notebook", - description: "Open an existing notebook or build one with live Python or R outputs.", - prompt: - "Find the most relevant notebook in this project, inspect it before running anything, then execute or repair it cell by cell. Preserve outputs and summarize the result and environment.", - shortcut: "Jupyter · Python · R", - icon: "notebook", - }, - { - id: "protein-design", - group: "compute", - title: "Design a protein", - description: "Define constraints, generate candidates, score structure, and preserve model provenance.", - prompt: - "Design protein candidates for the stated objective. Recover the target, interface, motif, sequence, and developability constraints before choosing tools. Use available structure prediction, RFdiffusion, ProteinMPNN, LigandMPNN, ESM, Boltz, Chai, or related workflows only where installed or accessible. Generate a small auditable candidate set, score failure modes, render structures, and preserve every model/version/seed/config.", - shortcut: "ESM · Boltz · RFdiffusion · MPNN", - icon: "atom", - }, - { - id: "molecular-docking", - group: "compute", - title: "Dock molecules", - description: "Prepare receptor and ligands, validate the box, compare poses, and inspect interactions.", - prompt: - "Run a careful molecular docking workflow on the relevant receptor and ligands. Validate chemistry, protonation, cofactors, missing residues, and the binding-site box first. Use an available DiffDock, GNINA, or AutoDock Vina path, include a control or redocking check, compare poses and scores without overstating affinity, render key interactions, and save prepared inputs and configs.", - shortcut: "DiffDock · GNINA · Vina", - icon: "atom", - }, - { - id: "molecular-dynamics", - group: "compute", - title: "Run molecular dynamics", - description: "Prepare, minimize, equilibrate, simulate, and validate a molecular system.", - prompt: - "Plan and run the relevant molecular-dynamics workflow with OpenMM, GROMACS, or AMBER if available. Validate force fields, protonation, solvent, ions, restraints, timestep, and ensemble; separate minimization, equilibration, and production; checkpoint long runs; monitor stability; and report RMSD/RMSF and decision-relevant observables with exact configs.", - shortcut: "OpenMM · GROMACS · AMBER", - icon: "activity", - }, - { - id: "train-model", - group: "compute", - title: "Train a model", - description: "Profile data, choose compute, track experiments, checkpoint, and compare honestly.", - prompt: - "Build a reproducible model-training run for this project. Inspect data splits and leakage risks first, establish a baseline and metric, estimate compute and memory, choose local or managed compute, capture the environment and random seeds, stream logs, save checkpoints and artifacts, evaluate on a held-out set, and run the reviewer gate before reporting results.", - shortcut: "PyTorch · TRL · scikit-learn", - icon: "activity", - }, - { - id: "run-pipeline", - group: "compute", - title: "Run a bioinformatics pipeline", - description: "Detect workflow definitions, validate inputs, execute safely, and collect reports.", - prompt: - "Find and run the most appropriate workflow in this project using Nextflow, nf-core, Snakemake, WDL/Cromwell, or the repository's own runner. Validate samplesheets, references, profiles, containers, and expected outputs first. Start with a dry run or small test when possible, use managed compute for long work, stream logs, collect reports and artifacts, and record failed steps clearly.", - shortcut: "Nextflow · nf-core · Snakemake", - icon: "reproduce", - }, - { - id: "survey-literature", - group: "discover", - title: "Survey the literature", - description: "Turn a research question into a sourced map of claims, methods, and open gaps.", - prompt: - "Build a focused literature survey for my research question. Separate established evidence from inference, compare methods and datasets, capture citations, and finish with the highest-value unanswered questions.", - shortcut: "papers · citations · claims", - icon: "search", - }, - { - id: "clinical-trials", - group: "discover", - title: "Compare clinical trials", - description: "Map designs, eligibility, endpoints, status, and evidence gaps across trials.", - prompt: - "Compare clinical trials relevant to the question using real registry records. Normalize phase, population, eligibility, interventions, comparators, endpoints, enrollment, status, dates, and reported results. Distinguish planned from completed evidence, identify endpoint or population mismatches, cite registry identifiers, and produce a comparison table with explicit caveats.", - shortcut: "ClinicalTrials.gov · endpoints", - icon: "search", - }, - { - id: "target-prioritization", - group: "discover", - title: "Prioritize a target", - description: "Combine genetics, expression, pathways, tractability, and safety evidence.", - prompt: - "Build a target-prioritization report for the disease or phenotype. Combine Open Targets, genetics, expression, pathways, protein structure, known drugs, tractability, and safety evidence; preserve evidence provenance; score dimensions separately before any aggregate ranking; surface contradictory evidence; and finish with the experiments that would most change the decision.", - shortcut: "Open Targets · genetics · pathways", - icon: "network", - }, - { - id: "reproduce-result", - group: "discover", - title: "Reproduce a result", - description: "Trace a claim to code and data, define a criterion, run checks, and record evidence.", - prompt: - "Reproduce the target result in this project. Identify the exact claim, code, data, configuration, and success criterion before running it. Record failures as evidence and finish with a supported, weakened, rejected, or not-tested verdict.", - shortcut: "claim · code · evidence", - icon: "reproduce", - }, - { - id: "compare-runs", - group: "compute", - title: "Compare experiments", - description: "Normalize metrics, surface confounders, and choose a winner without hiding failures.", - prompt: - "Compare the experiment runs in this project. Normalize their configurations and metrics, flag confounders and failed runs, visualize the decision-relevant differences, and recommend the next experiment.", - shortcut: "metrics · configs · failures", - icon: "compare", - }, - { - id: "verify-citations", - group: "communicate", - title: "Verify citations & claims", - description: "Check that sources exist, support the text, and match every quoted number.", - prompt: - "Audit the citations and scientific claims in the relevant report or manuscript. Resolve every DOI, PMID, arXiv id, URL, or bibliography key; verify that each source actually supports the attributed claim; compare quoted numbers against the source; flag missing, weak, mismatched, or contradictory citations; and record findings in the provenance graph.", - shortcut: "DOI · PMID · claim support", - icon: "reproduce", - }, - { - id: "build-figure", - group: "communicate", - title: "Build a publication figure", - description: "Turn project evidence into a clear, accessible, export-ready multi-panel figure.", - prompt: - "Create a publication-quality figure from the relevant project data and results. Identify the exact claim each panel supports, choose statistically honest encodings, preserve units and uncertainty, use accessible colors and readable typography, link every panel to its source data and code, and export editable source plus SVG, PNG, and PDF versions where supported.", - shortcut: "SVG · PNG · PDF · provenance", - icon: "report", - }, - { - id: "write-report", - group: "communicate", - title: "Write a research report", - description: "Synthesize project evidence into a clear report with figures, caveats, and citations.", - prompt: - "Draft a research report from the evidence in this project. Use a concise abstract, methods, results, limitations, and next steps. Cite source files and claims precisely, and reuse existing figures where they support the text.", - shortcut: "Markdown · LaTeX · PDF", - icon: "report", - }, -] - -export const researchSuggestions = researchWorkflows.filter((workflow) => - ["analyze-data", "run-notebook", "survey-literature"].includes(workflow.id), -) - -const groups: Array<{ id: ResearchWorkflow["group"]; title: string }> = [ - { id: "analyze", title: "Analyze" }, - { id: "compute", title: "Compute" }, - { id: "discover", title: "Discover" }, - { id: "communicate", title: "Communicate" }, -] - -export function workflowGroups() { - return groups.map((group) => ({ - ...group, - workflows: researchWorkflows.filter((workflow) => workflow.group === group.id), - })) -} - -export function workflowPrompt(workflow: ResearchWorkflow, artifacts: number) { - if (artifacts <= 0) return workflow.prompt - return `Your workspace contains ${artifacts.toLocaleString("en-US")} research artifacts. ${workflow.prompt}` -} diff --git a/frontend/workspace/src/components/session/review-workflow.test.ts b/frontend/workspace/src/components/session/review-workflow.test.ts index e79b27ca..8d7ff3a2 100644 --- a/frontend/workspace/src/components/session/review-workflow.test.ts +++ b/frontend/workspace/src/components/session/review-workflow.test.ts @@ -16,16 +16,13 @@ function sources(dir: string): string[] { } describe("reviewer workflow truth pass", () => { - test("the session header launches the reviewer directly", () => { + test("the session header does not expose a manual reviewer control", () => { const session = read("pages/session.tsx") - expect(session).toContain("Run review") - expect(session).toContain("`/session/${id}/review`") - expect(session).toContain('{ method: "POST" }') - expect(session).toContain('toast.success("review started"') - // Disabled without an open, idle session. - expect(session).toContain("reviewDisabled") - expect(session).toContain('!params.id || params.id === "new" || working()') + expect(session).not.toContain("Run review") + expect(session).not.toContain("openscience:run-review") + expect(session).not.toContain("reviewDisabled") + expect(session).not.toContain("workspace-header__review") }) test("no surface prefills chat to spawn the reviewer", () => { @@ -57,7 +54,7 @@ describe("reviewer workflow truth pass", () => { expect(specialists).toContain('"/settings/review"') expect(specialists).toContain("Automatically review significant results") - expect(specialists).toContain("Runs the reviewer after a result is saved as a durable artifact.") + expect(specialists).toContain("Checks durable results without interrupting the active research session.") expect(specialists).toContain('method: "PUT"') }) diff --git a/frontend/workspace/src/components/session/session-new-view.tsx b/frontend/workspace/src/components/session/session-new-view.tsx deleted file mode 100644 index 9562a8f3..00000000 --- a/frontend/workspace/src/components/session/session-new-view.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function NewSessionView() { - return <main class="research-launchpad" data-component="research-launchpad" aria-label="New research session" /> -} diff --git a/frontend/workspace/src/components/settings-general.css b/frontend/workspace/src/components/settings-general.css new file mode 100644 index 00000000..e4191055 --- /dev/null +++ b/frontend/workspace/src/components/settings-general.css @@ -0,0 +1,34 @@ +.settings-sound-volume { + display: inline-flex; + width: min(190px, 100%); + max-width: 100%; + min-height: 32px; + align-items: center; + gap: 10px; + color: var(--text-weak); + font-variant-numeric: tabular-nums; +} + +.settings-sound-volume input { + width: auto; + min-width: 0; + flex: 1; + accent-color: var(--icon-strong-base); +} + +.settings-sound-volume input:focus-visible { + outline: 2px solid var(--border-selected); + outline-offset: 3px; +} + +.settings-sound-volume input:disabled { + opacity: 0.45; +} + +.settings-sound-volume output { + flex: 0 0 auto; + width: 4ch; + color: var(--text-base); + font-size: var(--font-size-small); + text-align: right; +} diff --git a/frontend/workspace/src/components/settings-general.tsx b/frontend/workspace/src/components/settings-general.tsx index 9c482806..91f80d17 100644 --- a/frontend/workspace/src/components/settings-general.tsx +++ b/frontend/workspace/src/components/settings-general.tsx @@ -1,4 +1,4 @@ -import { Component, For, createMemo, type JSX } from "solid-js" +import { Component, For, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@synsci/ui/button" import { Select } from "@synsci/ui/select" @@ -10,7 +10,8 @@ import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { URLS } from "@/config/urls" -import { Link } from "./link" +import { PanelBody, PanelHeader, PanelScroll, Section as SettingsSection } from "./settings/_shared" +import "./settings-general.css" let demoSoundState = { cleanup: undefined as (() => void) | undefined, @@ -19,7 +20,7 @@ let demoSoundState = { // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. -const playDemoSound = (src: string) => { +const playDemoSound = (src: string, volume: number) => { if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -27,11 +28,11 @@ const playDemoSound = (src: string) => { clearTimeout(demoSoundState.timeout) demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + demoSoundState.cleanup = playSound(src, volume) }, 100) } -// The appearance / theme / notification / sound / update controls, without any +// The appearance / notification / sound / update controls, without any // outer scroll wrapper or header — so the new General settings panel can compose // them below its Account / Model / Licensing sections. `SettingsGeneral` below // keeps the standalone panel (scroll + header) for any legacy mount. @@ -41,6 +42,12 @@ export const AppearanceSections: Component = () => { const platform = usePlatform() const settings = useSettings() + onCleanup(() => { + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup?.() + demoSoundState = { cleanup: undefined, timeout: undefined } + }) + const [store, setStore] = createStore({ checking: false, }) @@ -50,7 +57,7 @@ export const AppearanceSections: Component = () => { setStore("checking", true) void platform - .checkUpdate() + .checkUpdate({ refresh: true }) .then((result) => { if (!result.updateAvailable) { showToast({ @@ -99,33 +106,6 @@ export const AppearanceSections: Component = () => { .finally(() => setStore("checking", false)) } - // Swatch data for the theme picker — pull representative seed colors from the - // active mode's variant so each swatch previews how the theme actually looks. - const themeSwatches = createMemo(() => { - const mode = theme.mode() - return Object.entries(theme.themes()).map(([id, def]) => { - const variant = mode === "dark" ? def.dark : def.light - const seeds = variant?.seeds - return { - id, - name: def.name ?? id, - bg: seeds?.neutral ?? "#111111", - dots: [ - seeds?.primary ?? seeds?.interactive ?? "#888888", - seeds?.info ?? "#6ea8fe", - seeds?.success ?? "#38b000", - seeds?.warning ?? "#f7a14d", - ], - } - }) - }) - - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - const languageOptions = createMemo(() => language.locales.map((locale) => ({ value: locale, @@ -133,49 +113,37 @@ export const AppearanceSections: Component = () => { })), ) + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + const soundOptions = [...SOUND_OPTIONS] return ( - <div class="flex flex-col gap-8 w-full max-w-[760px]"> - {/* Appearance Section */} - <div class="flex flex-col gap-3"> - <h3 class="text-13-medium text-text-weak tracking-wide">{language.t("settings.general.section.appearance")}</h3> - - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> - <SettingsRow - title={language.t("settings.general.row.language.title")} - description={language.t("settings.general.row.language.description")} - > - <Select - aria-label={language.t("settings.general.row.language.title")} - options={languageOptions()} - current={languageOptions().find((o) => o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </SettingsRow> - + <> + <SettingsSection title={language.t("settings.general.section.appearance")}> + <div class="settings-card"> <SettingsRow title={language.t("settings.general.row.appearance.title")} description={language.t("settings.general.row.appearance.description")} > - <div class="inline-flex items-center gap-0.5 p-0.5 rounded-xs border border-border-weak-base bg-surface-base"> + <div + role="group" + aria-label={language.t("settings.general.row.appearance.title")} + class="inline-flex max-w-full items-center rounded-md border border-border-weak-base bg-surface-base p-0.5" + > <For each={colorSchemeOptions()}> {(option) => ( <button type="button" - class="h-7 px-3 rounded-xs text-13-medium transition-colors" + aria-pressed={theme.colorScheme() === option.value} + class="h-8 min-w-[56px] rounded-sm px-2.5 text-12-medium text-text-weak transition-colors duration-150 hover:text-text-strong focus-visible:z-10" classList={{ - "bg-surface-raised-base-active text-text-strong shadow-xs": theme.colorScheme() === option.value, - "text-text-weak hover:text-text-strong": theme.colorScheme() !== option.value, + "bg-surface-raised-strong text-text-strong shadow-xs": theme.colorScheme() === option.value, }} onClick={() => theme.setColorScheme(option.value)} - onMouseEnter={() => theme.previewColorScheme(option.value)} - onMouseLeave={() => theme.cancelPreview()} > {option.label} </button> @@ -183,92 +151,28 @@ export const AppearanceSections: Component = () => { </For> </div> </SettingsRow> - </div> - </div> - - {/* Theme Section */} - <div class="flex flex-col gap-3"> - <div class="flex flex-col gap-0.5"> - <h3 class="text-13-medium text-text-weak tracking-wide">{language.t("settings.general.row.theme.title")}</h3> - <p class="text-12-regular text-text-weak"> - {language.t("settings.general.row.theme.description")}{" "} - <Link href={URLS.docsThemes}>{language.t("common.learnMore")}</Link> - </p> - </div> - - <div class="grid grid-cols-2 sm:grid-cols-3 gap-2.5"> - <For each={themeSwatches()}> - {(swatch) => ( - <button - type="button" - aria-label={`Use ${swatch.name} theme (${swatch.id})`} - class="group flex flex-col gap-2 p-2 rounded-[4px] text-left transition-all" - style={{ - border: - theme.themeId() === swatch.id - ? "2px solid var(--color-text-interactive-base, var(--color-text-strong))" - : "2px solid var(--color-border-weak-base)", - background: - theme.themeId() === swatch.id - ? "var(--color-surface-raised-base-active, transparent)" - : "transparent", - }} - onClick={() => theme.setTheme(swatch.id)} - onMouseEnter={() => theme.previewTheme(swatch.id)} - onMouseLeave={() => theme.cancelPreview()} - > - <div - class="h-14 rounded-xs flex items-center gap-1.5 px-3" - style={{ - background: swatch.bg, - "box-shadow": "inset 0 0 0 1px rgba(128,128,128,0.18)", - }} - > - <For each={swatch.dots}> - {(dot) => ( - <span - class="size-3.5 rounded-full" - style={{ background: dot, "box-shadow": "0 1px 2px rgba(0,0,0,0.25)" }} - /> - )} - </For> - </div> - <span class="text-12-medium text-text-strong px-1 truncate">{swatch.name}</span> - </button> - )} - </For> - </div> - </div> - - {/* Layout Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide pb-2"> - {language.t("settings.general.section.layout")} - </h3> - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> <SettingsRow - title={language.t("settings.general.layout.showChanges.title")} - description={language.t("settings.general.layout.showChanges.description")} + title={language.t("settings.general.row.language.title")} + description={language.t("settings.general.row.language.description")} > - <Switch - hideLabel - checked={settings.ui.showChangesView()} - onChange={(checked) => settings.ui.setShowChangesView(checked)} - > - {language.t("settings.general.layout.showChanges.title")} - </Switch> + <Select + aria-label={language.t("settings.general.row.language.title")} + options={languageOptions()} + current={languageOptions().find((o) => o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> </SettingsRow> </div> - </div> + </SettingsSection> - {/* System notifications Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide pb-2"> - {language.t("settings.general.section.notifications")} - </h3> - - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> + <SettingsSection title={language.t("settings.general.section.notifications")}> + <div class="settings-card"> <SettingsRow title={language.t("settings.general.notifications.agent.title")} description={language.t("settings.general.notifications.agent.description")} @@ -308,33 +212,62 @@ export const AppearanceSections: Component = () => { </Switch> </SettingsRow> </div> - </div> + </SettingsSection> + + <SettingsSection title={language.t("settings.general.section.sounds")}> + <div class="settings-card"> + <SettingsRow + title={language.t("settings.general.sounds.enabled.title")} + description={language.t("settings.general.sounds.enabled.description")} + > + <Switch + hideLabel + checked={settings.sounds.enabled()} + onChange={(checked) => settings.sounds.setEnabled(checked)} + > + {language.t("settings.general.sounds.enabled.title")} + </Switch> + </SettingsRow> - {/* Sound effects Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide pb-2"> - {language.t("settings.general.section.sounds")} - </h3> + <SettingsRow + title={language.t("settings.general.sounds.volume.title")} + description={language.t("settings.general.sounds.volume.description")} + > + <label class="settings-sound-volume"> + <span class="sr-only">{language.t("settings.general.sounds.volume.title")}</span> + <input + type="range" + min="0" + max="1" + step="0.05" + value={settings.sounds.volume()} + disabled={!settings.sounds.enabled()} + aria-valuetext={`${Math.round(settings.sounds.volume() * 100)}%`} + onInput={(event) => settings.sounds.setVolume(Number(event.currentTarget.value))} + /> + <output aria-live="polite">{Math.round(settings.sounds.volume() * 100)}%</output> + </label> + </SettingsRow> - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> <SettingsRow title={language.t("settings.general.sounds.agent.title")} description={language.t("settings.general.sounds.agent.description")} > <Select aria-label={language.t("settings.general.sounds.agent.title")} + disabled={!settings.sounds.enabled()} options={soundOptions} current={soundOptions.find((o) => o.id === settings.sounds.agent())} value={(o) => o.id} label={(o) => language.t(o.label)} onHighlight={(option) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} onSelect={(option) => { if (!option) return settings.sounds.setAgent(option.id) - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} variant="secondary" size="small" @@ -348,18 +281,19 @@ export const AppearanceSections: Component = () => { > <Select aria-label={language.t("settings.general.sounds.permissions.title")} + disabled={!settings.sounds.enabled()} options={soundOptions} current={soundOptions.find((o) => o.id === settings.sounds.permissions())} value={(o) => o.id} label={(o) => language.t(o.label)} onHighlight={(option) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} onSelect={(option) => { if (!option) return settings.sounds.setPermissions(option.id) - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} variant="secondary" size="small" @@ -373,18 +307,19 @@ export const AppearanceSections: Component = () => { > <Select aria-label={language.t("settings.general.sounds.errors.title")} + disabled={!settings.sounds.enabled()} options={soundOptions} current={soundOptions.find((o) => o.id === settings.sounds.errors())} value={(o) => o.id} label={(o) => language.t(o.label)} onHighlight={(option) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} onSelect={(option) => { if (!option) return settings.sounds.setErrors(option.id) - playDemoSound(option.src) + playDemoSound(option.src, settings.sounds.volume()) }} variant="secondary" size="small" @@ -392,15 +327,10 @@ export const AppearanceSections: Component = () => { /> </SettingsRow> </div> - </div> - - {/* Updates Section */} - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide pb-2"> - {language.t("settings.general.section.updates")} - </h3> + </SettingsSection> - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> + <SettingsSection title={language.t("settings.general.section.updates")}> + <div class="settings-card"> <SettingsRow title={language.t("settings.updates.row.startup.title")} description={language.t("settings.updates.row.startup.description")} @@ -419,9 +349,9 @@ export const AppearanceSections: Component = () => { title={language.t("settings.general.row.releaseNotes.title")} description={language.t("settings.general.row.releaseNotes.description")} > - <div class="flex items-center gap-2"> + <div class="flex max-w-full flex-wrap items-center justify-end gap-2"> <Button size="small" variant="secondary" onClick={() => platform.openLink(URLS.releases)}> - view notes + View notes </Button> <Switch hideLabel @@ -444,8 +374,8 @@ export const AppearanceSections: Component = () => { </Button> </SettingsRow> </div> - </div> - </div> + </SettingsSection> + </> ) } @@ -454,14 +384,12 @@ export const AppearanceSections: Component = () => { export const SettingsGeneral: Component = () => { const language = useLanguage() return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-8 sm:pb-10"> - <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> - <div class="flex flex-col gap-1 pt-8 pb-8 max-w-[760px]"> - <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2> - </div> - </div> - <AppearanceSections /> - </div> + <PanelScroll> + <PanelHeader title={language.t("settings.tab.general")} description="Appearance, notifications, and updates." /> + <PanelBody> + <AppearanceSections /> + </PanelBody> + </PanelScroll> ) } @@ -473,12 +401,12 @@ interface SettingsRowProps { const SettingsRow: Component<SettingsRowProps> = (props) => { return ( - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5 border-b border-border-weak-base last:border-none"> - <div class="flex flex-col gap-0.5 min-w-0"> + <div class="settings-row justify-between"> + <div class="flex min-w-0 flex-1 basis-[220px] flex-col gap-0.5"> <span class="text-14-medium text-text-strong">{props.title}</span> <span class="text-12-regular text-text-weak">{props.description}</span> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="ml-auto max-w-full flex-shrink-0">{props.children}</div> </div> ) } diff --git a/frontend/workspace/src/components/settings-permissions.test.ts b/frontend/workspace/src/components/settings-permissions.test.ts new file mode 100644 index 00000000..ed6e8e8d --- /dev/null +++ b/frontend/workspace/src/components/settings-permissions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { + commitPermissionDefault, + permissionActionFor, + permissionChange, + permissionDefaultFor, +} from "./settings/permission-defaults" + +const panel = await Bun.file(new URL("./settings/Permissions.tsx", import.meta.url)).text() + +describe("permission defaults shown in Settings", () => { + test("matches the backend base rules for sensitive fallback permissions", () => { + expect(permissionDefaultFor("external_directory")).toBe("ask") + expect(permissionDefaultFor("doom_loop")).toBe("ask") + }) + + test("matches the backend wildcard allow default for ordinary tools", () => { + for (const permission of ["read", "edit", "bash", "webfetch", "skill"]) { + expect(permissionDefaultFor(permission)).toBe("allow") + } + }) + + test("preserves patterned rules while changing only their default action", () => { + const change = permissionChange({ bash: { "git *": "allow", "*": "ask" }, read: "deny" }, "bash", "deny") + expect(change.patch as Record<string, unknown>).toEqual({ bash: { "git *": "allow", "*": "deny" } }) + expect(change.optimistic).toEqual({ bash: { "git *": "allow", "*": "deny" }, read: "deny" }) + expect(permissionActionFor(change.optimistic, "bash")).toBe("deny") + }) + + test("serializes config writes and restores the confirmed snapshot on failure", async () => { + let permission: unknown = { bash: "allow", read: "deny" } + let busy = false + const hooks = { + isBusy: () => busy, + permission: () => permission, + setPermission: (next: unknown) => (permission = next), + setBusy: (next: boolean) => (busy = next), + write: async () => { + throw new Error("write failed") + }, + } + expect(await commitPermissionDefault("bash", "deny", hooks)).toEqual({ ok: false, error: "write failed" }) + expect(permission).toEqual({ bash: "allow", read: "deny" }) + expect(busy).toBe(false) + }) + + test("keeps project trust explicit and revocable from the reachable Permissions panel", () => { + expect(panel).toContain("sdk.client.project.trust.get(input)") + expect(panel).toContain("sdk.client.project.trust.update({") + expect(panel).toContain('title: trusted ? "Trust this project?" : "Revoke project trust?"') + expect(panel).toContain("body: trusted ? { trusted: true, root: status.root } : { trusted: false }") + expect(panel).toContain('trust()?.canExecuteProjectCode ? "Revoke trust" : "Trust project"') + expect(panel).toContain("If sandboxing is off or unavailable") + expect(panel).not.toContain("verified OpenScience sandbox") + expect(panel).not.toContain("verified OS sandbox") + }) +}) diff --git a/frontend/workspace/src/components/settings-permissions.tsx b/frontend/workspace/src/components/settings-permissions.tsx index b20f91dd..5bc174bb 100644 --- a/frontend/workspace/src/components/settings-permissions.tsx +++ b/frontend/workspace/src/components/settings-permissions.tsx @@ -1,14 +1,12 @@ import { Select } from "@synsci/ui/select" +import { Icon, type IconProps } from "@synsci/ui/icon" import { showToast } from "@synsci/ui/toast" -import { Component, For, createMemo, type JSX } from "solid-js" +import { Component, For, createMemo, createSignal, type JSX } from "solid-js" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" - -type PermissionAction = "allow" | "ask" | "deny" - -type PermissionObject = Record<string, PermissionAction> -type PermissionValue = PermissionAction | PermissionObject | string[] | undefined -type PermissionMap = Record<string, PermissionValue> +import type { Config } from "@synsci/sdk/v2/client" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./settings/_shared" +import { commitPermissionDefault, permissionActionFor, type PermissionAction } from "./settings/permission-defaults" const ACTIONS = [ { value: "allow", label: "settings.permissions.action.allow" }, @@ -19,132 +17,121 @@ const ACTIONS = [ const ITEMS = [ { id: "read", + icon: "file", title: "settings.permissions.tool.read.title", description: "settings.permissions.tool.read.description", }, { id: "edit", + icon: "edit", title: "settings.permissions.tool.edit.title", description: "settings.permissions.tool.edit.description", }, { id: "glob", + icon: "folder-tree", title: "settings.permissions.tool.glob.title", description: "settings.permissions.tool.glob.description", }, { id: "grep", + icon: "magnifying-glass", title: "settings.permissions.tool.grep.title", description: "settings.permissions.tool.grep.description", }, { id: "list", + icon: "bullet-list", title: "settings.permissions.tool.list.title", description: "settings.permissions.tool.list.description", }, { id: "bash", + icon: "console", title: "settings.permissions.tool.bash.title", description: "settings.permissions.tool.bash.description", }, { id: "task", + icon: "task", title: "settings.permissions.tool.task.title", description: "settings.permissions.tool.task.description", }, { id: "skill", + icon: "flask", title: "settings.permissions.tool.skill.title", description: "settings.permissions.tool.skill.description", }, { id: "lsp", + icon: "code", title: "settings.permissions.tool.lsp.title", description: "settings.permissions.tool.lsp.description", }, { id: "todoread", + icon: "checklist", title: "settings.permissions.tool.todoread.title", description: "settings.permissions.tool.todoread.description", }, { id: "todowrite", + icon: "checklist", title: "settings.permissions.tool.todowrite.title", description: "settings.permissions.tool.todowrite.description", }, { id: "planwrite", + icon: "branch", title: "settings.permissions.tool.planwrite.title", description: "settings.permissions.tool.planwrite.description", }, { id: "webfetch", + icon: "link", title: "settings.permissions.tool.webfetch.title", description: "settings.permissions.tool.webfetch.description", }, { id: "websearch", + icon: "magnifying-glass", title: "settings.permissions.tool.websearch.title", description: "settings.permissions.tool.websearch.description", }, { id: "codesearch", + icon: "code-lines", title: "settings.permissions.tool.codesearch.title", description: "settings.permissions.tool.codesearch.description", }, { id: "external_directory", + icon: "folder", title: "settings.permissions.tool.external_directory.title", description: "settings.permissions.tool.external_directory.description", }, { id: "doom_loop", + icon: "alert-circle", title: "settings.permissions.tool.doom_loop.title", description: "settings.permissions.tool.doom_loop.description", }, ] as const -const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"]) - -function toMap(value: unknown): PermissionMap { - if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap - - const action = getAction(value) - if (action) return { "*": action } - - return {} -} - -function getAction(value: unknown): PermissionAction | undefined { - if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction - return -} - -function getRuleDefault(value: unknown): PermissionAction | undefined { - const action = getAction(value) - if (action) return action - - if (!value || typeof value !== "object" || Array.isArray(value)) return - - return getAction((value as Record<string, unknown>)["*"]) -} - export const SettingsPermissions: Component = () => { const language = useLanguage() return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> - <div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[760px]"> - <h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2> - <p class="text-13-regular text-text-weak">{language.t("settings.permissions.description")}</p> - </div> - </div> - - <div class="flex flex-col gap-6 px-4 pb-10 sm:px-8 max-w-[760px]"> + <PanelScroll> + <PanelHeader + title={language.t("settings.permissions.title")} + description={language.t("settings.permissions.description")} + /> + <PanelBody> <PermissionToolDefaults /> - </div> - </div> + </PanelBody> + </PanelScroll> ) } @@ -153,6 +140,7 @@ export const SettingsPermissions: Component = () => { export const PermissionToolDefaults: Component = () => { const globalSync = useGlobalSync() const language = useLanguage() + const [busy, setBusy] = createSignal(false) const actions = createMemo( (): Array<{ value: PermissionAction; label: string }> => @@ -162,44 +150,37 @@ export const PermissionToolDefaults: Component = () => { })), ) - const permission = createMemo(() => toMap(globalSync.data.config.permission)) - - const actionFor = (id: string): PermissionAction => { - const value = permission()[id] - const direct = getRuleDefault(value) - if (direct) return direct - const wildcard = getRuleDefault(permission()["*"]) - if (wildcard) return wildcard - return "allow" - } - const setPermission = async (id: string, action: PermissionAction) => { - const before = globalSync.data.config.permission - const map = toMap(before) - const existing = map[id] - const nextValue = - existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action - globalSync.set("config", "permission", { ...map, [id]: nextValue }) - globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => { - globalSync.set("config", "permission", before) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message }) + const result = await commitPermissionDefault(id, action, { + isBusy: busy, + permission: () => globalSync.data.config.permission, + setPermission: (permission) => globalSync.set("config", "permission", permission as Config["permission"]), + setBusy, + write: (permission) => globalSync.updateConfig({ permission }), }) + if (!result.ok && "error" in result) { + showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: result.error }) + } } return ( - <div class="flex flex-col gap-2"> - <h3 class="text-13-medium text-text-weak tracking-wide">{language.t("settings.permissions.section.tools")}</h3> - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> + <Section + title={language.t("settings.permissions.section.tools")} + description="Set the default response for each class of tool request." + > + <div class="settings-card"> <For each={ITEMS}> {(item) => ( - <SettingsRow title={language.t(item.title)} description={language.t(item.description)}> + <SettingsRow icon={item.icon} title={language.t(item.title)} description={language.t(item.description)}> <Select aria-label={`${language.t(item.title)} permission`} options={actions()} - current={actions().find((o) => o.value === actionFor(item.id))} + current={actions().find( + (o) => o.value === permissionActionFor(globalSync.data.config.permission, item.id), + )} value={(o) => o.value} label={(o) => o.label} + disabled={busy()} onSelect={(option) => option && setPermission(item.id, option.value)} variant="secondary" size="small" @@ -209,11 +190,12 @@ export const PermissionToolDefaults: Component = () => { )} </For> </div> - </div> + </Section> ) } interface SettingsRowProps { + icon: IconProps["name"] title: string description: string children: JSX.Element @@ -221,12 +203,13 @@ interface SettingsRowProps { const SettingsRow: Component<SettingsRowProps> = (props) => { return ( - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5 border-b border-border-weak-base last:border-none"> - <div class="flex flex-col gap-0.5 min-w-0"> + <div class="settings-row justify-between"> + <Icon name={props.icon} size="small" class="shrink-0 text-icon-weak-base" /> + <div class="flex min-w-0 flex-1 basis-[220px] flex-col gap-0.5"> <span class="text-14-medium text-text-strong">{props.title}</span> <span class="text-12-regular text-text-weak">{props.description}</span> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="ml-auto max-w-full flex-shrink-0">{props.children}</div> </div> ) } diff --git a/frontend/workspace/src/components/settings/CodexConnection.tsx b/frontend/workspace/src/components/settings/CodexConnection.tsx index aaa07694..c452dd89 100644 --- a/frontend/workspace/src/components/settings/CodexConnection.tsx +++ b/frontend/workspace/src/components/settings/CodexConnection.tsx @@ -1,5 +1,7 @@ import { Show, createMemo, createSignal, type Component } from "solid-js" import { Button } from "@synsci/ui/button" +import { useDialog } from "@synsci/ui/context/dialog" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { usePlatform } from "@/context/platform" @@ -15,6 +17,7 @@ export const CodexConnection: Component<{ const globalSync = useGlobalSync() const platform = usePlatform() const providers = useProviders() + const dialog = useDialog() const [busy, setBusy] = createSignal(false) const connected = createMemo(() => providers.connected().some((provider) => provider.id === "openai-codex")) @@ -42,7 +45,13 @@ export const CodexConnection: Component<{ } const disconnect = async () => { - if (!window.confirm("Disconnect ChatGPT / Codex from this machine?")) return + const confirmed = await confirmDialog(dialog, { + title: "Disconnect ChatGPT / Codex?", + message: "This removes the saved sign-in from this machine. You can sign in again at any time.", + confirmLabel: "Disconnect", + danger: true, + }) + if (!confirmed) return setBusy(true) props.onError?.(undefined) const outcome = await credentialChange({ @@ -58,31 +67,51 @@ export const CodexConnection: Component<{ } return ( - <div class="flex flex-col gap-3 rounded-[4px] border border-border-weak-base bg-surface-base/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> - <div class="flex min-w-0 items-center gap-2.5"> - <ProviderLogo id="openai-codex" label="OpenAI" connected={connected()} /> - <div class="flex min-w-0 flex-col gap-0.5"> - <span class="text-13-medium text-text-strong">Sign in with ChatGPT</span> - <span class="text-12-regular text-text-weak"> - Keep Codex model access through your ChatGPT Plus, Pro, or Business plan. - </span> + <div class="settings-card settings-connection-card models-connection-card"> + <div class="settings-row models-compact-row models-connection-row"> + <div class="models-connection-identity"> + <ProviderLogo id="openai-codex" label="OpenAI" connected={connected()} /> + <div class="flex min-w-0 flex-col gap-0.5"> + <span class="text-13-medium text-text-strong">Sign in with ChatGPT</span> + <span class="text-12-regular text-text-weak"> + Keep Codex model access through your ChatGPT Plus, Pro, or Business plan. + </span> + </div> </div> - </div> - <Show - when={!connected()} - fallback={ - <div class="flex shrink-0 items-center gap-2"> - <span class="text-12-regular text-text-weak">Connected</span> - <Button size="small" variant="secondary" disabled={busy()} onClick={() => void disconnect()}> - Disconnect + <Show + when={!connected()} + fallback={ + <div class="models-connection-actions"> + <div class="settings-status" data-tone="ready"> + <span class="settings-status__dot" aria-hidden="true" /> + Connected + </div> + <Button + class="settings-panel-action settings-panel-action--quiet models-secondary-action" + size="small" + variant="secondary" + disabled={busy()} + onClick={() => void disconnect()} + > + Disconnect + </Button> + </div> + } + > + <span class="models-row-action"> + <Button + class="settings-panel-action models-primary-action" + type="button" + size="small" + variant="primary" + disabled={busy()} + onClick={() => void connect()} + > + {busy() ? "Waiting for ChatGPT…" : "Sign in with ChatGPT"} </Button> - </div> - } - > - <Button type="button" size="small" variant="primary" disabled={busy()} onClick={() => void connect()}> - {busy() ? "Waiting for ChatGPT…" : "Sign in with ChatGPT"} - </Button> - </Show> + </span> + </Show> + </div> </div> ) } diff --git a/frontend/workspace/src/components/settings/Compute.test.ts b/frontend/workspace/src/components/settings/Compute.test.ts new file mode 100644 index 00000000..36c9839a --- /dev/null +++ b/frontend/workspace/src/components/settings/Compute.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" + +const source = Bun.file(new URL("./Compute.tsx", import.meta.url)).text() + +describe("Compute Settings interaction contract", () => { + test("isolates busy state so unrelated compute controls remain usable", async () => { + const component = await source + + expect(component).toContain("busy: {} as Record<string, boolean>") + expect(component).toContain('const modalBusy = () => hasBusyPrefix("modal:")') + expect(component).toContain("const busyKey = `ssh:test:${item.id}`") + expect(component).toContain("const busyKey = `ssh:remove:${item.id}`") + expect(component).not.toContain("disabled={Boolean(busy())}") + }) + + test("preserves unsaved Modal edits across background resource updates", async () => { + const component = await source + + expect(component).toContain("let modalHydrated = false") + expect(component).toContain("if (modalHydrated && dirty()) return") + }) + + test("uses the shared flat panel language and responsive action regions", async () => { + const component = await source + + expect(component).toContain('class="settings-preferences-panel settings-preferences-panel--compute"') + expect(component).toContain('class="settings-compute-host-actions"') + expect(component).toContain('class="settings-compute-actions"') + expect(component).toContain('import "./preference-panels.css"') + }) +}) diff --git a/frontend/workspace/src/components/settings/Compute.tsx b/frontend/workspace/src/components/settings/Compute.tsx index 2111b6f3..fc566d70 100644 --- a/frontend/workspace/src/components/settings/Compute.tsx +++ b/frontend/workspace/src/components/settings/Compute.tsx @@ -1,6 +1,7 @@ import { For, Show, createEffect, createResource, type Component, type JSX, type Setter } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@synsci/ui/button" +import { Icon, type IconProps } from "@synsci/ui/icon" import { Select } from "@synsci/ui/select" import { Switch } from "@synsci/ui/switch" import { showToast } from "@synsci/ui/toast" @@ -11,6 +12,8 @@ import { confirmDialog } from "@/atlas/dialogs" import { settingsApi } from "./api" import { CredentialServices } from "./CredentialServices" import { ProviderLogo } from "./ProviderLogo" +import { PanelBody, PanelHeader, PanelScroll } from "./_shared" +import "./preference-panels.css" type Scheduler = "none" | "slurm" | "pbs" type Host = { @@ -21,6 +24,8 @@ type Host = { port?: number scheduler: Scheduler workdir?: string + fingerprint?: string + concurrency: number } type Provider = { id: string @@ -50,6 +55,7 @@ type Probe = { gpu: boolean slurm: boolean pbs: boolean + fingerprint?: string error?: string } type Notice = { @@ -73,7 +79,7 @@ const Compute: Component = () => { const [data, control] = createResource(() => call<Info>()) const [state, setState] = createStore({ adding: false, - busy: undefined as string | undefined, + busy: {} as Record<string, boolean>, probes: {} as Record<string, Probe>, label: "", host: "", @@ -81,6 +87,7 @@ const Compute: Component = () => { port: "", scheduler: "none" as Scheduler, workdir: "", + sshConcurrency: "4", token: "", secret: "", app: "", @@ -93,11 +100,20 @@ const Compute: Component = () => { }) const adding = () => state.adding const setAdding: Setter<boolean> = (value) => setState("adding", value) - const busy = () => state.busy - const setBusy = (value: string | undefined) => { - setState("busy", value) + const setBusy = (key: string, value: boolean) => { + setState("busy", (current) => { + const next = { ...current } + if (value) next[key] = true + else delete next[key] + return next + }) return value } + const isBusy = (key: string) => Boolean(state.busy[key]) + const hasBusyPrefix = (prefix: string) => Object.keys(state.busy).some((key) => key.startsWith(prefix)) + const modalBusy = () => hasBusyPrefix("modal:") + const sshMutationBusy = () => isBusy("ssh:add") || hasBusyPrefix("ssh:remove:") + const hostBusy = (id: string) => isBusy(`ssh:test:${id}`) || isBusy(`ssh:remove:${id}`) const probes = () => state.probes const setProbes: Setter<Record<string, Probe>> = (value) => setState("probes", value) const label = () => state.label @@ -112,6 +128,8 @@ const Compute: Component = () => { const setScheduler: Setter<Scheduler> = (value) => setState("scheduler", value) const workdir = () => state.workdir const setWorkdir: Setter<string> = (value) => setState("workdir", value) + const sshConcurrency = () => state.sshConcurrency + const setSshConcurrency: Setter<string> = (value) => setState("sshConcurrency", value) const token = () => state.token const setToken: Setter<string> = (value) => setState("token", value) const secret = () => state.secret @@ -164,7 +182,7 @@ const Compute: Component = () => { const defaultsNotice = (): Notice | undefined => { if (!modal()?.connected) return undefined const current = defaults() - if (current?.tone === "error" || busy() === "modal:save") return current + if (current?.tone === "error" || isBusy("modal:save")) return current if (dirty()) { return { tone: "neutral", @@ -181,18 +199,23 @@ const Compute: Component = () => { ) } + let modalHydrated = false createEffect(() => { const value = data()?.modal if (!value) return + // Refreshes from connection/toggle calls must not erase edits the user is + // still making in the defaults form. + if (modalHydrated && dirty()) return setApp(value.app) setImage(value.image) setNetwork(value.network) setTimeout(String(value.timeout_minutes)) setConcurrency(String(value.concurrency)) + modalHydrated = true }) const connect = async () => { - setBusy("modal:connect") + setBusy("modal:connect", true) setConnection({ tone: "neutral", title: "Saving Modal token…" }) const next = await call<Info>("/provider/modal", { method: "POST", @@ -203,7 +226,7 @@ const Compute: Component = () => { showToast({ title: "Could not save Modal token", description: detail }) return undefined }) - setBusy(undefined) + setBusy("modal:connect", false) if (!next) return control.mutate(next) setToken("") @@ -221,7 +244,7 @@ const Compute: Component = () => { } const configure = async () => { - setBusy("modal:configure") + setBusy("modal:configure", true) setConnection({ tone: "neutral", title: "Configuring Modal…", detail: "Reading the active ~/.modal.toml profile." }) const next = await call<Info>("/modal/configure", { method: "POST" }).catch((error) => { const detail = message(error) @@ -229,7 +252,7 @@ const Compute: Component = () => { showToast({ title: "Could not configure Modal", description: detail }) return undefined }) - setBusy(undefined) + setBusy("modal:configure", false) if (!next) return control.mutate(next) setConnection({ @@ -245,7 +268,7 @@ const Compute: Component = () => { } const toggle = async (enabled: boolean) => { - setBusy("modal:toggle") + setBusy("modal:toggle", true) setConnection({ tone: "neutral", title: enabled ? "Enabling Modal…" : "Disabling Modal…" }) const next = await call<Info>("/provider/modal/enabled", { method: "POST", @@ -256,7 +279,7 @@ const Compute: Component = () => { showToast({ title: "Could not update Modal", description: detail }) return undefined }) - setBusy(undefined) + setBusy("modal:toggle", false) if (!next) return control.mutate(next) setConnection({ @@ -269,7 +292,7 @@ const Compute: Component = () => { } const check = async () => { - setBusy("modal:check") + setBusy("modal:check", true) setConnection({ tone: "neutral", title: "Checking Modal connection…", detail: "Verifying the configured profile." }) const result = await call<{ ok: true; sdk: string }>("/modal/check", { method: "POST" }).catch((error) => { const detail = message(error) @@ -277,7 +300,7 @@ const Compute: Component = () => { showToast({ title: "Modal connection failed", description: detail }) return undefined }) - setBusy(undefined) + setBusy("modal:check", false) if (!result) return setConnection({ tone: "success", @@ -308,7 +331,7 @@ const Compute: Component = () => { showToast({ title: "Invalid Modal concurrency", description: "Use a whole number from 1 to 100." }) return } - setBusy("modal:save") + setBusy("modal:save", true) setDefaults({ tone: "neutral", title: "Saving Modal defaults…" }) const next = await call<Info>("/modal", { method: "PATCH", @@ -325,7 +348,7 @@ const Compute: Component = () => { showToast({ title: "Could not save Modal defaults", description: detail }) return undefined }) - setBusy(undefined) + setBusy("modal:save", false) if (!next) return control.mutate(next) setDefaults({ @@ -343,6 +366,7 @@ const Compute: Component = () => { setPort("") setScheduler("none") setWorkdir("") + setSshConcurrency("4") setAdding(false) } @@ -352,7 +376,12 @@ const Compute: Component = () => { showToast({ title: "Invalid SSH port", description: "Use a port between 1 and 65535." }) return } - setBusy("add") + const limit = Number(sshConcurrency()) + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + showToast({ title: "Invalid SSH concurrency", description: "Use a whole number from 1 to 100." }) + return + } + setBusy("ssh:add", true) const next = await call<Info>("/ssh", { method: "POST", body: JSON.stringify({ @@ -362,12 +391,13 @@ const Compute: Component = () => { port: parsedPort, scheduler: scheduler(), workdir: workdir().trim() || undefined, + concurrency: limit, }), }).catch((error) => { showToast({ title: "Could not add SSH host", description: message(error) }) return undefined }) - setBusy(undefined) + setBusy("ssh:add", false) if (!next) return control.mutate(next) reset() @@ -375,7 +405,8 @@ const Compute: Component = () => { } const test = async (item: Host) => { - setBusy(`test:${item.id}`) + const busyKey = `ssh:test:${item.id}` + setBusy(busyKey, true) const result = await call<Probe>(`/ssh/${item.id}/test`, { method: "POST" }).catch((error) => ({ ok: false, host: item.label, @@ -387,7 +418,7 @@ const Compute: Component = () => { error: message(error), })) setProbes((current) => ({ ...current, [item.id]: result })) - setBusy(undefined) + setBusy(busyKey, false) showToast({ variant: result.ok ? "success" : "error", title: result.ok ? `${item.label} is reachable` : `Could not reach ${item.label}`, @@ -403,324 +434,402 @@ const Compute: Component = () => { danger: true, }) if (!confirmed) return - setBusy(`remove:${item.id}`) + const busyKey = `ssh:remove:${item.id}` + setBusy(busyKey, true) const next = await call<Info>(`/ssh/${item.id}`, { method: "DELETE" }).catch((error) => { showToast({ title: "Could not remove SSH host", description: message(error) }) return undefined }) - setBusy(undefined) + setBusy(busyKey, false) if (!next) return control.mutate(next) setProbes((current) => Object.fromEntries(Object.entries(current).filter(([id]) => id !== item.id))) } return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Compute</h2> - <p class="text-13-regular text-text-weak"> - Run close to home or head to the cloud when the work gets bigger. - </p> - </div> - </div> - - <div class="settings-page-body"> - <Section title="Local machine" subtitle="The default execution target for this OpenScience server."> - <Panel> - <Row title="This machine" subtitle="Persistent kernels and batch jobs use the active session sandbox."> - <Badge tone="ready">Available</Badge> - </Row> - </Panel> - </Section> + <PanelScroll> + <div class="settings-preferences-panel settings-preferences-panel--compute"> + <PanelHeader title="Compute" description="Choose where agent-managed Python, R, shell, and batch work runs." /> + <PanelBody> + <Section + title="Local runtimes" + subtitle="Persistent scientific runtimes start automatically when your work needs them." + > + <Panel> + <Row + icon="braces" + title="Python and R kernels" + subtitle="Session-owned kernels preserve in-memory state and appear in the workspace Compute panel." + > + <Badge tone="ready">Automatic</Badge> + </Row> + <Row + icon="console" + title="Shell and local jobs" + subtitle="Commands run inside the active session sandbox and remain controllable while live." + > + <Badge tone="ready">Ready</Badge> + </Row> + </Panel> + </Section> - <CredentialServices - category="compute" - title="Cloud credentials" - description="Connect a cloud once. Credentials stay encrypted locally and go only to the tools that need them." - /> + <CredentialServices + category="compute" + title="Cloud credentials" + description="Connect a cloud once. Credentials stay encrypted locally and go only to the tools that need them." + /> - <Section title="Modal" subtitle="Run explicitly approved jobs in isolated Modal sandboxes using your account."> - <Panel> - <div class="flex flex-col gap-4 px-4 py-4"> - <div class="flex flex-wrap items-center justify-between gap-4"> - <div class="flex min-w-0 items-center gap-2.5"> - <ProviderLogo id="modal" label="Modal" connected={modal()?.connected} /> - <div class="flex min-w-0 flex-col gap-0.5"> - <span class="text-14-medium text-text-strong">Modal compute</span> - <span class="text-12-regular text-text-weak"> - {modal()?.connected - ? modal()?.source === "modal_toml" - ? "Using the active profile in ~/.modal.toml." - : "Token stored locally and encrypted." - : data()?.modal_file.ready - ? "Modal CLI configuration found at ~/.modal.toml." - : data()?.modal_file.found - ? "Modal config found, but its active profile has no usable token." - : "Enter the token ID and secret from Modal."} - </span> + <Section + title="Modal" + subtitle="Run explicitly approved jobs in isolated Modal sandboxes using your account." + > + <Panel> + <div class="settings-compute-card" aria-busy={modalBusy() ? "true" : undefined}> + <div class="settings-compute-provider-row"> + <div class="flex min-w-0 flex-1 basis-[240px] items-center gap-2.5"> + <ProviderLogo id="modal" label="Modal" connected={modal()?.connected} /> + <div class="flex min-w-0 flex-col gap-0.5"> + <span class="text-14-medium text-text-strong">Modal compute</span> + <span class="text-12-regular text-text-weak"> + {modal()?.connected + ? modal()?.source === "modal_toml" + ? "Using the active profile in ~/.modal.toml." + : "Token stored locally and encrypted." + : data()?.modal_file.ready + ? "Modal CLI configuration found at ~/.modal.toml." + : data()?.modal_file.found + ? "Modal config found, but its active profile has no usable token." + : "Enter the token ID and secret from Modal."} + </span> + </div> </div> + <Show when={modal()?.connected}> + <Switch + hideLabel + checked={modal()?.enabled ?? false} + disabled={modalBusy()} + onChange={(value) => void toggle(value)} + > + Enable Modal + </Switch> + </Show> </div> - <Show when={modal()?.connected}> - <Switch - hideLabel - checked={modal()?.enabled ?? false} - disabled={Boolean(busy())} - onChange={(value) => void toggle(value)} - > - Enable Modal - </Switch> + <Show when={connectionNotice()}>{(notice) => <NoticeBox notice={notice()} />}</Show> + <Show when={!data.loading && !modal()?.connected && data()?.modal_file.ready}> + <div class="settings-alert flex-wrap"> + <p class="min-w-0 flex-1 basis-[240px] text-12-regular text-text-weak"> + Configure OpenScience to use this profile. Token values stay in the Modal config file. + </p> + <span class="ml-auto shrink-0"> + <Button + class="settings-panel-action" + size="small" + variant="primary" + disabled={modalBusy()} + onClick={() => void configure()} + > + {isBusy("modal:configure") ? "Configuring…" : "Configure"} + </Button> + </span> + </div> </Show> - </div> - <Show when={connectionNotice()}>{(notice) => <NoticeBox notice={notice()} />}</Show> - <Show when={!data.loading && !modal()?.connected && data()?.modal_file.ready}> - <div class="flex flex-wrap items-center justify-between gap-3 rounded-[6px] border border-border-weak-base bg-surface-base px-3 py-3"> - <p class="text-12-regular text-text-weak"> - Configure OpenScience to use this profile. Token values stay in the Modal config file. - </p> - <Button size="small" variant="primary" disabled={Boolean(busy())} onClick={() => void configure()}> - {busy() === "modal:configure" ? "Configuring…" : "Configure"} - </Button> - </div> - </Show> - <Show when={!data.loading && !modal()?.connected && !data()?.modal_file.ready}> - <div class="flex flex-col gap-2"> + <Show when={!data.loading && !modal()?.connected && !data()?.modal_file.ready}> + <div class="flex flex-col gap-2"> + <div class="grid gap-3 sm:grid-cols-2"> + <Field label="Modal token ID" value={token()} placeholder="ak-…" onInput={setToken} /> + <Field + label="Modal token secret" + value={secret()} + placeholder="as-…" + type="password" + onInput={setSecret} + /> + </div> + <div class="flex justify-end"> + <Button + class="settings-panel-action" + size="small" + variant="primary" + disabled={!token().trim() || !secret().trim() || modalBusy()} + onClick={() => void connect()} + > + {isBusy("modal:connect") ? "Saving…" : "Save token"} + </Button> + </div> + </div> + </Show> + <Show when={modal()?.connected}> <div class="grid gap-3 sm:grid-cols-2"> - <Field label="Modal token ID" value={token()} placeholder="ak-…" onInput={setToken} /> + <Field label="Modal app" value={app()} placeholder="openscience" onInput={setApp} /> + <Field label="Default image" value={image()} placeholder="python:3.12-slim" onInput={setImage} /> + <label class="flex min-w-0 flex-col gap-1.5"> + <span class="text-12-medium text-text-strong">Network</span> + <select + aria-label="Modal network" + class="settings-control px-3 text-13-regular text-text-strong" + value={network()} + onChange={(event) => setNetwork(event.currentTarget.value as Modal["network"])} + > + <option value="none">Blocked</option> + <option value="unrestricted">Unrestricted</option> + </select> + </label> + <Field + label="Default timeout (minutes)" + value={timeout()} + placeholder="60" + inputMode="numeric" + onInput={setTimeout} + /> <Field - label="Modal token secret" - value={secret()} - placeholder="as-…" - type="password" - onInput={setSecret} + label="Concurrent jobs" + value={concurrency()} + placeholder="10" + inputMode="numeric" + onInput={setConcurrency} /> </div> - <div class="flex justify-end"> + <p class="text-11-regular text-text-weak"> + Agents use this as their starting limit and may choose a different timeout for the workload. Every + approval card shows the final limit before dispatch. + </p> + <Show when={defaultsNotice()}>{(notice) => <NoticeBox notice={notice()} />}</Show> + <p class="text-11-regular text-text-weak"> + The token is never added to agent shells. Turning Modal off prevents new credential resolution and + dispatch. + </p> + <div class="settings-compute-actions"> + <Button + class="settings-panel-action settings-panel-action--quiet" + size="small" + variant="secondary" + disabled={!modal()?.enabled || modalBusy()} + onClick={() => void check()} + > + {isBusy("modal:check") ? "Testing…" : "Test connection"} + </Button> <Button + class="settings-panel-action" size="small" variant="primary" - disabled={!token().trim() || !secret().trim() || Boolean(busy())} - onClick={() => void connect()} + disabled={!app().trim() || !image().trim() || modalBusy()} + onClick={() => void saveModal()} > - {busy() === "modal:connect" ? "Saving…" : "Save token"} + {isBusy("modal:save") ? "Saving…" : "Save defaults"} </Button> </div> - </div> - </Show> - <Show when={modal()?.connected}> - <div class="grid gap-3 sm:grid-cols-2"> - <Field label="Modal app" value={app()} placeholder="openscience" onInput={setApp} /> - <Field label="Default image" value={image()} placeholder="python:3.12-slim" onInput={setImage} /> - <label class="flex flex-col gap-1.5"> - <span class="text-12-medium text-text-strong">Network</span> - <select - aria-label="Modal network" - class="h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong" - value={network()} - onChange={(event) => setNetwork(event.currentTarget.value as Modal["network"])} - > - <option value="none">Blocked</option> - <option value="unrestricted">Unrestricted</option> - </select> - </label> - <Field - label="Default timeout (minutes)" - value={timeout()} - placeholder="60" - inputMode="numeric" - onInput={setTimeout} - /> - <Field - label="Concurrent jobs" - value={concurrency()} - placeholder="10" - inputMode="numeric" - onInput={setConcurrency} - /> - </div> - <p class="text-11-regular text-text-weak"> - Agents use this as their starting limit and may choose a different timeout for the workload. Every - approval card shows the final limit before dispatch. - </p> - <Show when={defaultsNotice()}>{(notice) => <NoticeBox notice={notice()} />}</Show> - <p class="text-11-regular text-text-weak"> - The token is never added to agent shells. Turning Modal off prevents new credential resolution and - dispatch. - </p> - <div class="flex justify-end gap-2"> - <Button - size="small" - variant="secondary" - disabled={!modal()?.enabled || Boolean(busy())} - onClick={() => void check()} - > - {busy() === "modal:check" ? "Testing…" : "Test connection"} - </Button> - <Button - size="small" - variant="primary" - disabled={!app().trim() || !image().trim() || Boolean(busy())} - onClick={() => void saveModal()} - > - {busy() === "modal:save" ? "Saving…" : "Save defaults"} - </Button> - </div> - </Show> - </div> - </Panel> - </Section> + </Show> + </div> + </Panel> + </Section> - <Section title="Remote compute" subtitle="Connect directly over SSH. Atlas is not required."> - <div class="flex flex-col gap-3"> - <Show - when={!data.loading} - fallback={ - <Panel> - <Row title="Loading SSH hosts" subtitle="Reading saved compute profiles."> - <Badge>Loading</Badge> - </Row> - </Panel> - } - > + <Section + title="Remote hosts" + subtitle="Pin a host key, then dispatch staged jobs through your active SSH agent." + > + <div class="settings-compute-remote" aria-busy={hasBusyPrefix("ssh:") ? "true" : undefined}> <Show - when={(data()?.ssh_hosts.length ?? 0) > 0} + when={!data.loading} fallback={ <Panel> - <Row - title="No remote hosts connected" - subtitle="Add a plain SSH, Slurm, or PBS host, then run a real connection check." - > - <Button size="small" variant="secondary" onClick={() => setAdding(true)}> - Add host - </Button> + <Row icon="server" title="Loading SSH hosts" subtitle="Reading saved compute profiles."> + <Badge tone="muted">Loading</Badge> </Row> </Panel> } > - <Panel> - <For each={data()?.ssh_hosts}> - {(item) => { - const probe = () => probes()[item.id] - return ( - <div class="flex flex-wrap items-center gap-4 px-4 py-3.5 border-b border-border-weak-base last:border-none"> - <div class="min-w-0 flex-1"> - <div class="flex items-center gap-2"> - <span class="text-14-medium text-text-strong truncate">{item.label}</span> - <Badge tone={probe()?.ok ? "ready" : undefined}> - {probe()?.ok ? "verified now" : schedulerLabel(item.scheduler)} - </Badge> - </div> - <p class="text-12-regular text-text-weak mt-0.5 truncate"> - {destination(item)} - {item.workdir ? ` · ${item.workdir}` : ""} - </p> - <Show when={probe()}> - {(result) => ( - <p - class={ - result().ok - ? "text-11-regular text-text-success mt-1" - : "text-11-regular text-text-danger mt-1" - } - > - {result().ok - ? `${result().latency_ms} ms · ${capabilities(result())}` - : result().error} + <Show + when={(data()?.ssh_hosts.length ?? 0) > 0} + fallback={ + <Panel> + <Row + icon="server" + title="No remote hosts connected" + subtitle="Add a plain SSH, Slurm, or PBS host, then run a real connection check." + > + <Button + class="settings-panel-action settings-panel-action--quiet" + size="small" + variant="secondary" + disabled={sshMutationBusy()} + onClick={() => setAdding(true)} + > + Add host + </Button> + </Row> + </Panel> + } + > + <Panel> + <For each={data()?.ssh_hosts}> + {(item) => { + const probe = () => probes()[item.id] + return ( + <div class="settings-row settings-compute-host-row"> + <div class="settings-compute-host-copy"> + <div class="settings-row-icon mt-0.5" aria-hidden="true"> + <Icon name="server" size="small" /> + </div> + <div class="min-w-0 flex-1"> + <div class="flex min-w-0 flex-wrap items-center gap-2"> + <span class="truncate text-14-medium text-text-strong">{item.label}</span> + <Badge tone={probe()?.ok || item.fingerprint ? "ready" : "muted"}> + {probe()?.ok + ? "Ready to dispatch" + : item.fingerprint + ? "Host key pinned" + : schedulerLabel(item.scheduler)} + </Badge> + </div> + <p class="mt-0.5 truncate text-12-regular text-text-weak"> + {destination(item)} + {item.workdir ? ` · ${item.workdir}` : ""} </p> - )} - </Show> - </div> - <div class="flex items-center gap-2"> - <Button - size="small" - variant="secondary" - disabled={Boolean(busy())} - onClick={() => void test(item)} - > - {busy() === `test:${item.id}` ? "Testing…" : "Test"} - </Button> - <Button - size="small" - variant="ghost" - disabled={Boolean(busy())} - onClick={() => void remove(item)} - > - Remove - </Button> + <Show when={probe()}> + {(result) => ( + <p + class={ + result().ok + ? "mt-1 text-11-regular text-text-success" + : "mt-1 text-11-regular text-text-danger" + } + > + {result().ok + ? `${result().latency_ms} ms · ${capabilities(result())}` + : result().error} + </p> + )} + </Show> + <Show when={item.fingerprint}> + <p + class="mt-1 truncate font-mono text-11-regular text-text-weak" + title={item.fingerprint} + > + {item.fingerprint} · {item.concurrency} concurrent job + {item.concurrency === 1 ? "" : "s"} + </p> + </Show> + </div> + </div> + <div class="settings-compute-host-actions"> + <Button + class="settings-panel-action settings-panel-action--quiet" + size="small" + variant="secondary" + disabled={hostBusy(item.id) || sshMutationBusy()} + onClick={() => void test(item)} + > + {isBusy(`ssh:test:${item.id}`) ? "Testing…" : "Test"} + </Button> + <Button + class="settings-panel-action settings-panel-action--danger-quiet" + size="small" + variant="ghost" + disabled={hostBusy(item.id) || sshMutationBusy()} + onClick={() => void remove(item)} + > + Remove + </Button> + </div> </div> - </div> - ) - }} - </For> - </Panel> + ) + }} + </For> + </Panel> + </Show> </Show> - </Show> - <Show when={(data()?.ssh_hosts.length ?? 0) > 0 && !adding()}> - <Button size="small" variant="secondary" onClick={() => setAdding(true)}> - Add another host - </Button> - </Show> + <Show when={(data()?.ssh_hosts.length ?? 0) > 0 && !adding()}> + <Button + class="settings-panel-action settings-panel-action--quiet self-start" + size="small" + variant="secondary" + disabled={sshMutationBusy()} + onClick={() => setAdding(true)} + > + Add another host + </Button> + </Show> - <Show when={adding()}> - <form - class="grid gap-4 border border-border-weak-base rounded-[6px] bg-surface-base/40 p-4" - onSubmit={(event) => { - event.preventDefault() - void add() - }} - > - <div> - <h4 class="text-14-medium text-text-strong">New SSH host</h4> - <p class="text-12-regular text-text-weak mt-0.5"> - OpenScience uses your existing SSH agent and config. Private keys are never copied into the app. - </p> - </div> - <div class="grid gap-3 sm:grid-cols-2"> - <Field label="Name" value={label()} placeholder="Lab cluster" onInput={setLabel} /> - <Field label="Hostname" value={host()} placeholder="hpc.example.edu" onInput={setHost} /> - <Field label="User" value={user()} placeholder="Optional" onInput={setUser} /> - <Field label="Port" value={port()} placeholder="22" inputMode="numeric" onInput={setPort} /> - <label class="flex flex-col gap-1.5"> - <span class="text-12-medium text-text-strong">Scheduler</span> - <Select - aria-label="Scheduler" - options={schedulers} - current={schedulers.find((item) => item.value === scheduler())} - value={(item) => item.value} - label={(item) => item.label} - onSelect={(item) => item && setScheduler(item.value)} - variant="secondary" - size="small" - triggerVariant="settings" + <Show when={adding()}> + <form + class="settings-card settings-form-card grid gap-5" + onSubmit={(event) => { + event.preventDefault() + void add() + }} + > + <div class="flex items-start gap-3"> + <div class="settings-row-icon mt-0.5" aria-hidden="true"> + <Icon name="server" size="small" /> + </div> + <div class="min-w-0"> + <h4 class="text-14-medium text-text-strong">New SSH host</h4> + <p class="mt-0.5 text-12-regular text-text-weak"> + OpenScience uses your active SSH agent, pins the tested host key, and never copies private keys. + </p> + </div> + </div> + <div class="grid gap-3 sm:grid-cols-2"> + <Field label="Name" value={label()} placeholder="Lab cluster" onInput={setLabel} /> + <Field label="Hostname" value={host()} placeholder="hpc.example.edu" onInput={setHost} /> + <Field label="User" value={user()} placeholder="Optional" onInput={setUser} /> + <Field label="Port" value={port()} placeholder="22" inputMode="numeric" onInput={setPort} /> + <label class="flex min-w-0 flex-col gap-1.5"> + <span class="text-12-medium text-text-strong">Scheduler</span> + <Select + aria-label="Scheduler" + options={schedulers} + current={schedulers.find((item) => item.value === scheduler())} + value={(item) => item.value} + label={(item) => item.label} + onSelect={(item) => item && setScheduler(item.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> + </label> + <Field + label="Remote working directory" + value={workdir()} + placeholder="~/research" + onInput={setWorkdir} /> - </label> - <Field - label="Remote working directory" - value={workdir()} - placeholder="~/research" - onInput={setWorkdir} - /> - </div> - <div class="flex items-center justify-end gap-2"> - <Button size="small" variant="ghost" disabled={busy() === "add"} onClick={reset}> - Cancel - </Button> - <Button - type="submit" - size="small" - variant="primary" - disabled={!label().trim() || !host().trim() || busy() === "add"} - > - {busy() === "add" ? "Adding…" : "Add host"} - </Button> - </div> - </form> - </Show> - </div> - </Section> + <Field + label="Concurrent jobs" + value={sshConcurrency()} + placeholder="4" + inputMode="numeric" + onInput={setSshConcurrency} + /> + </div> + <div class="settings-compute-actions"> + <Button + class="settings-panel-action settings-panel-action--quiet" + size="small" + variant="ghost" + disabled={isBusy("ssh:add")} + onClick={reset} + > + Cancel + </Button> + <Button + class="settings-panel-action" + type="submit" + size="small" + variant="primary" + disabled={!label().trim() || !host().trim() || sshMutationBusy()} + > + {isBusy("ssh:add") ? "Adding…" : "Add host"} + </Button> + </div> + </form> + </Show> + </div> + </Section> + </PanelBody> </div> - </div> + </PanelScroll> ) } @@ -734,10 +843,10 @@ const Field: Component<{ inputMode?: JSX.InputHTMLAttributes<HTMLInputElement>["inputMode"] onInput: (value: string) => void }> = (props) => ( - <label class="flex flex-col gap-1.5"> + <label class="flex min-w-0 flex-col gap-1.5"> <span class="text-12-medium text-text-strong">{props.label}</span> <input - class="h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong outline-none focus:border-border-strong-base" + class="settings-field" value={props.value} placeholder={props.placeholder} type={props.type} @@ -748,39 +857,34 @@ const Field: Component<{ ) const Section: Component<{ title: string; subtitle: string; children: JSX.Element }> = (props) => ( - <section class="flex flex-col gap-3"> - <div class="flex flex-col gap-0.5"> - <h3 class="text-13-medium text-text-weak tracking-wide">{props.title}</h3> - <p class="text-12-regular text-text-weak">{props.subtitle}</p> + <section class="settings-section"> + <div class="settings-section-heading"> + <div> + <h3>{props.title}</h3> + <p>{props.subtitle}</p> + </div> </div> {props.children} </section> ) -const Panel: Component<{ children: JSX.Element }> = (props) => ( - <div class="border border-border-weak-base rounded-[6px] overflow-hidden bg-surface-base/40">{props.children}</div> -) +const Panel: Component<{ children: JSX.Element }> = (props) => <div class="settings-card">{props.children}</div> const NoticeBox: Component<{ notice: Notice }> = (props) => ( <div role={props.notice.tone === "error" ? "alert" : "status"} aria-live="polite" - class="flex items-start gap-2.5 rounded-[6px] border px-3 py-2.5" + class="settings-alert !items-start" + data-tone={props.notice.tone === "error" ? "critical" : undefined} classList={{ - "border-border-weak-base bg-surface-base/60": props.notice.tone === "neutral", - "border-text-success/30 bg-text-success/5": props.notice.tone === "success", - "border-text-danger/30 bg-text-danger/5": props.notice.tone === "error", + "text-text-success": props.notice.tone === "success", }} > - <span - class="mt-1 size-1.5 shrink-0 rounded-full" - classList={{ - "bg-icon-weak-base": props.notice.tone === "neutral", - "bg-icon-success-base": props.notice.tone === "success", - "bg-text-danger": props.notice.tone === "error", - }} - aria-hidden="true" - /> + <Show when={props.notice.tone !== "neutral"}> + <div class="settings-alert__icon" aria-hidden="true"> + <Icon name={props.notice.tone === "success" ? "circle-check" : "alert-circle"} size="small" /> + </div> + </Show> <div class="min-w-0"> <p class="text-12-medium" @@ -799,27 +903,28 @@ const NoticeBox: Component<{ notice: Notice }> = (props) => ( </div> ) -const Row: Component<{ title: string; subtitle: string; children: JSX.Element }> = (props) => ( - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-14-medium text-text-strong">{props.title}</span> - <span class="text-12-regular text-text-weak">{props.subtitle}</span> +const Row: Component<{ icon: IconProps["name"]; title: string; subtitle: string; children: JSX.Element }> = (props) => ( + <div class="settings-row settings-compute-summary-row"> + <div class="flex min-w-0 flex-1 basis-[220px] items-center gap-3"> + <div class="settings-row-icon" aria-hidden="true"> + <Icon name={props.icon} size="small" /> + </div> + <div class="flex min-w-0 flex-col gap-0.5"> + <span class="text-14-medium text-text-strong">{props.title}</span> + <span class="text-12-regular text-text-weak">{props.subtitle}</span> + </div> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="settings-compute-summary-action">{props.children}</div> </div> ) -const Badge: Component<{ tone?: "ready"; children: JSX.Element }> = (props) => ( - <span - class={ - props.tone === "ready" - ? "inline-flex items-center gap-1.5 text-11-medium text-text-success" - : "inline-flex items-center rounded-[4px] px-2 py-1 text-11-medium text-text-weak bg-surface-base" - } - > - {props.tone === "ready" ? <span class="size-1.5 rounded-full bg-current" aria-hidden="true" /> : undefined} +const Badge: Component<{ tone: "ready" | "muted"; children: JSX.Element }> = (props) => ( + <div class="settings-status" data-tone={props.tone}> + <Show when={props.tone === "ready"}> + <span class="settings-status__dot" aria-hidden="true" /> + </Show> {props.children} - </span> + </div> ) function destination(host: Host) { diff --git a/frontend/workspace/src/components/settings/Connectors.test.ts b/frontend/workspace/src/components/settings/Connectors.test.ts new file mode 100644 index 00000000..a4cfc11e --- /dev/null +++ b/frontend/workspace/src/components/settings/Connectors.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import { blankConnectorForm, buildConnectorConfig, connectorFormFromConfig, connectorIdentity } from "./connector-form" + +describe("Connector Settings form behavior", () => { + test("turns a quoted local command and environment fields into the persisted MCP payload", () => { + const state = { + ...blankConnectorForm("local"), + name: "filesystem", + command: 'npx -y "@modelcontextprotocol/server-filesystem" "/tmp/research data"', + env: '{"TOKEN":"secret"}', + timeout: "5000", + } + + expect(buildConnectorConfig(state)).toEqual({ + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp/research data"], + environment: { TOKEN: "secret" }, + timeout: 5000, + }) + }) + + test("round-trips masked remote secrets without exposing or erasing the saved values", () => { + const original = { + type: "remote" as const, + url: "https://mcp.example.org/mcp", + headers: { Authorization: "Bearer original" }, + oauth: { clientId: "client", clientSecret: "original-secret", scope: "tools" }, + } + const form = connectorFormFromConfig("remote", original) + + expect(form.headers).toContain("••••••••") + expect(form.clientSecret).toBe("••••••••") + expect(buildConnectorConfig(form)).toEqual(original) + }) + + test("rejects invalid JSON, URLs, and timeout values before an SDK write", () => { + expect(() => + buildConnectorConfig({ ...blankConnectorForm("local"), command: "node server.js", env: "[]" }), + ).toThrow("Environment must be a JSON object") + expect(() => buildConnectorConfig({ ...blankConnectorForm("remote"), url: "not a url" })).toThrow( + "Remote URL is invalid", + ) + expect(() => + buildConnectorConfig({ ...blankConnectorForm("remote"), url: "https://mcp.example.org", timeout: "1.5" }), + ).toThrow("Timeout must be a positive whole number") + }) + + test("uses recognizable connector identities and transport-specific fallbacks", () => { + expect( + connectorIdentity("code", { + type: "remote", + url: "https://api.github.com/mcp", + }), + ).toEqual({ icon: "github", label: "GitHub" }) + expect( + connectorIdentity("research files", { + type: "local", + command: ["npx", "@modelcontextprotocol/server-filesystem", "."], + }), + ).toEqual({ icon: "folder", label: "Filesystem" }) + expect( + connectorIdentity("literature", { + type: "remote", + url: "https://mcp.example.org/mcp", + }), + ).toEqual({ icon: "cloud", label: "Hosted server" }) + expect( + connectorIdentity("analysis", { + type: "local", + command: ["uvx", "analysis-mcp"], + }), + ).toEqual({ icon: "console", label: "Local process" }) + }) +}) diff --git a/frontend/workspace/src/components/settings/Connectors.tsx b/frontend/workspace/src/components/settings/Connectors.tsx index d5c7b8fe..1b4499c5 100644 --- a/frontend/workspace/src/components/settings/Connectors.tsx +++ b/frontend/workspace/src/components/settings/Connectors.tsx @@ -3,9 +3,12 @@ import { Switch } from "@synsci/ui/switch" import { Icon } from "@synsci/ui/icon" import { IconButton } from "@synsci/ui/icon-button" import { showToast } from "@synsci/ui/toast" +import { useDialog } from "@synsci/ui/context/dialog" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import type { Config, McpInspection, McpStatus } from "@synsci/sdk/v2/client" +import "./connectors.css" import { PanelScroll, PanelHeader, @@ -13,22 +16,24 @@ import { Toolbar, SearchInput, AddMenu, - Card, - Row, SectionLabel, EmptyState, FormField, FormButton, - Avatar, - Chip, } from "./_shared" -import { formatConnectorCommand, parseConnectorCommand } from "./connector-command" +import { + blankConnectorForm, + buildConnectorConfig, + connectorFormFromConfig, + connectorIdentity, + maskConnectorConfig, + type ConfiguredMcp, + type ConnectorFormState, + type McpType, + type OAuthMode, +} from "./connector-form" type McpConfig = NonNullable<Config["mcp"]>[string] -type McpType = "local" | "remote" -type OAuthMode = "off" | "auto" | "client" -type ConfiguredMcp = Extract<McpConfig, { type: McpType }> -const MASK = "••••••••" function isConfigured(value: McpConfig | undefined): value is ConfiguredMcp { return !!value && typeof value === "object" && "type" in value @@ -37,6 +42,7 @@ function isConfigured(value: McpConfig | undefined): value is ConfiguredMcp { export default function Connectors() { const sync = useGlobalSync() const sdk = useGlobalSDK() + const dialog = useDialog() const [status, setStatus] = createSignal<Record<string, McpStatus>>({}) const [details, setDetails] = createSignal<Record<string, McpInspection>>({}) @@ -45,7 +51,7 @@ export default function Connectors() { const [problem, setProblem] = createSignal("") const [expanded, setExpanded] = createSignal<string>() const [editing, setEditing] = createSignal<string | undefined>() - const [form, setForm] = createSignal<FormState | undefined>() + const [form, setForm] = createSignal<ConnectorFormState | undefined>() const entries = createMemo(() => Object.entries(sync.data.config.mcp ?? {}) @@ -88,17 +94,8 @@ export default function Connectors() { if (s.status === "needs_auth") return "Needs authentication" return "Needs client registration" } - // Wash the connector's avatar tile by connection state so status reads at a - // glance; a muted/off connector stays neutral. - function statusTint(s: McpStatus | undefined): string | undefined { - const d = dot(s) - if (d === "active") return "var(--color-success)" - if (d === "error") return "var(--color-error)" - if (d === "pending") return "var(--color-warning)" - return undefined - } - async function toggle(name: string, on: boolean) { + if (busy()) return const config = entries().find(([key]) => key === name)?.[1] if (!config) return setBusy(true) @@ -115,7 +112,14 @@ export default function Connectors() { } async function remove(name: string) { - if (!window.confirm(`Remove connector "${name}"? It will be disconnected and deleted from config.`)) return + if (busy()) return + const confirmed = await confirmDialog(dialog, { + title: `Remove "${name}"?`, + message: "This disconnects the connector and deletes it from your global OpenScience configuration.", + confirmLabel: "Remove connector", + danger: true, + }) + if (!confirmed) return setBusy(true) try { await sdk.client.mcp.config.remove({ name, scope: "global" }) @@ -134,6 +138,7 @@ export default function Connectors() { } async function authenticate(name: string) { + if (busy()) return setBusy(true) try { const result = await sdk.client.mcp.auth.authenticate({ name }) @@ -155,7 +160,14 @@ export default function Connectors() { } async function disconnectAuth(name: string) { - if (!window.confirm(`Disconnect "${name}" and remove its OAuth credentials from this machine?`)) return + if (busy()) return + const confirmed = await confirmDialog(dialog, { + title: `Disconnect "${name}"?`, + message: "This removes the connector's OAuth credentials from this machine. Its configuration stays in place.", + confirmLabel: "Disconnect", + danger: true, + }) + if (!confirmed) return setBusy(true) try { await sdk.client.mcp.auth.remove({ name }) @@ -170,11 +182,11 @@ export default function Connectors() { function openForm(type: McpType) { setEditing(undefined) - setForm(blankForm(type)) + setForm(blankConnectorForm(type)) } function editConnector(name: string, config: ConfiguredMcp) { setEditing(name) - setForm(formFromConfig(name, config)) + setForm(connectorFormFromConfig(name, config)) } function closeForm() { setForm(undefined) @@ -182,6 +194,7 @@ export default function Connectors() { } async function save() { + if (busy()) return const state = form() if (!state) return const name = state.name.trim() @@ -191,7 +204,7 @@ export default function Connectors() { } setBusy(true) try { - const config = buildConfig(state) + const config = buildConnectorConfig(state) const previous = editing() const result = await sdk.client.mcp.config.set({ name, config, scope: "global" }) if (previous && previous !== name) { @@ -202,7 +215,7 @@ export default function Connectors() { return next }) } - sync.set("config", "mcp", name, maskConfig(config)) + sync.set("config", "mcp", name, maskConnectorConfig(config)) const latest = result.data ?? {} setStatus(latest) closeForm() @@ -233,445 +246,363 @@ export default function Connectors() { return ( <PanelScroll> - <PanelHeader - title="Connectors" - description="Connect real MCP servers to give research agents access to external tools and data." - toolbar={ - <Show when={!form()}> - <Toolbar> - <SearchInput value={search()} onInput={setSearch} placeholder="Search connectors" /> - <AddMenu - label="Add connector" - items={[ - { - icon: "link", - label: "Remote URL", - description: "Connect a hosted MCP server over HTTP", - onSelect: () => openForm("remote"), - }, - { - icon: "console", - label: "Local command", - description: "Run an MCP server process locally", - onSelect: () => openForm("local"), - }, - ]} + <div class="connectors-panel"> + <PanelHeader + title="Connectors" + description="Connect MCP servers that provide external research tools and data." + toolbar={ + <Show when={!form()}> + <Toolbar> + <SearchInput + value={search()} + onInput={setSearch} + placeholder="Search connectors" + ariaLabel="Search connectors" + /> + <AddMenu + label="Add connector" + items={[ + { + icon: "cloud", + label: "Hosted server", + description: "Connect an MCP endpoint over HTTPS", + onSelect: () => openForm("remote"), + }, + { + icon: "console", + label: "Local process", + description: "Run a trusted MCP command on this machine", + onSelect: () => openForm("local"), + }, + ]} + /> + </Toolbar> + </Show> + } + /> + + <PanelBody> + <Show when={problem()}> + <div role="alert" class="settings-alert mb-4" data-tone="critical"> + <span class="text-12-regular">Connector status unavailable. {problem()}</span> + <button + type="button" + class="text-12-medium" + disabled={busy()} + onClick={() => void refresh().catch(() => undefined)} + > + Retry + </button> + </div> + </Show> + <Show when={form()}> + {(state) => ( + <ConnectorForm + state={state()} + editing={!!editing()} + busy={busy()} + onChange={setForm} + onSave={save} + onCancel={closeForm} /> - </Toolbar> + )} </Show> - } - /> - <PanelBody> - <Show when={problem()}> - <div - role="alert" - class="mb-4 flex items-center justify-between gap-3 rounded-[4px] border border-border-weak-base px-3 py-2" - style={{ color: "var(--color-error)" }} - > - <span class="text-12-regular">Connector status unavailable. {problem()}</span> - <button type="button" class="text-12-medium" disabled={busy()} onClick={() => void refresh()}> - Retry - </button> - </div> - </Show> - <Show when={form()}> - {(state) => ( - <ConnectorForm - state={state()} - editing={!!editing()} - busy={busy()} - onChange={setForm} - onSave={save} - onCancel={closeForm} - /> - )} - </Show> - - <Show when={!form()}> - <Show - when={entries().length > 0} - fallback={ - <Show - when={!search()} - fallback={ - <EmptyState - icon="mcp" - title="No matching connectors" - hint="Try a different name or clear the search." - /> - } - > - <div class="flex flex-col items-center gap-3 py-12 text-center"> - <div class="flex size-10 items-center justify-center rounded-[6px] bg-surface-raised-base text-icon-weak-base"> - <Icon name="mcp" size="normal" /> - </div> - <div class="flex flex-col gap-1"> - <span class="text-14-medium text-text-strong">Connect your research tools</span> - <p class="max-w-[380px] text-12-regular leading-relaxed text-text-weak"> - Add a hosted MCP server with optional OAuth, or run a trusted MCP command on this machine. - </p> - </div> - <div class="mt-1 flex flex-wrap items-center justify-center gap-2"> - <FormButton label="Add remote server" onClick={() => openForm("remote")} /> - <FormButton label="Add local command" variant="ghost" onClick={() => openForm("local")} /> + <Show when={!form()}> + <Show + when={entries().length > 0} + fallback={ + <Show + when={!search()} + fallback={ + <EmptyState + icon="mcp" + title="No matching connectors" + hint="Try a different name or clear the search." + /> + } + > + <div class="connectors-empty"> + <div class="connectors-empty__icon"> + <Icon name="mcp" size="normal" /> + </div> + <div class="connectors-empty__copy"> + <strong>Connect your research tools</strong> + <p>Add a hosted MCP server with optional OAuth, or run a trusted MCP command on this machine.</p> + </div> + <div class="connectors-empty__actions"> + <FormButton label="Hosted server" onClick={() => openForm("remote")} /> + <FormButton label="Local process" variant="ghost" onClick={() => openForm("local")} /> + </div> </div> - </div> - </Show> - } - > - <div class="flex flex-col gap-2"> - <SectionLabel label="Connectors" count={entries().length} /> - <Card> - <For each={entries()}> - {(entry) => { - const name = entry[0] - const config = entry[1] - const s = () => status()[name] - const detail = () => details()[name] - return ( - <Row> - <Avatar icon={config.type === "remote" ? "link" : "console"} tint={statusTint(s())} /> - <div class="min-w-0 flex-1"> - <div class="flex items-center gap-2"> - <span class="text-14-medium text-text-strong truncate">{name}</span> - <Chip>{config.type}</Chip> - <span - class="text-11-medium text-text-weak/70 truncate" - style={{ color: s()?.status === "failed" ? "var(--color-error)" : undefined }} - > + </Show> + } + > + <section class="settings-section connectors-section" aria-label="Configured connectors"> + <SectionLabel label="Connectors" count={entries().length} /> + <div class="connectors-list" role="list"> + <For each={entries()}> + {(entry) => { + const name = entry[0] + const config = entry[1] + const s = () => status()[name] + const detail = () => details()[name] + const identity = connectorIdentity(name, config) + return ( + <article + class="connectors-item" + data-expanded={expanded() === name ? "true" : undefined} + role="listitem" + > + <div class="connectors-row"> + <div class="connectors-identity" data-kind={identity.icon}> + <Icon name={identity.icon} size="small" /> + </div> + <div class="connectors-copy"> + <div class="connectors-copy__title"> + <strong>{name}</strong> + <span>{identity.label}</span> + </div> + <p title={config.type === "local" ? config.command.join(" ") : config.url}> + {config.type === "local" ? config.command.join(" ") : config.url} + </p> + <Show when={detail()}> + {(value) => ( + <div class="connectors-capability-summary"> + <span>{value().tools.length} tools</span> + <span>{value().resources.length} resources</span> + <span>{value().prompts.length} prompts</span> + </div> + )} + </Show> + </div> + <span class="connectors-status" data-tone={dot(s())}> + <span aria-hidden="true" /> {statusText(s())} </span> - </div> - <p class="text-12-regular text-text-weak truncate mt-0.5"> - {config.type === "local" ? config.command.join(" ") : config.url} - </p> - <Show when={detail()}> - {(value) => ( - <p class="text-11-regular text-text-weak/70 mt-1"> - {value().tools.length} tools · {value().resources.length} resources ·{" "} - {value().prompts.length} prompts - <Show when={value().auth}> · {value().auth?.replaceAll("_", " ")}</Show> - </p> - )} - </Show> - </div> - <div class="flex items-center gap-1"> - <Show when={config.type === "remote" && config.oauth !== false}> - <button - type="button" - class="rounded-[4px] border border-border-weak-base px-2 py-1 text-11-medium text-text-weak hover:text-text-strong" - disabled={busy()} - onClick={() => void authenticate(name)} - > - {detail()?.auth === "authenticated" ? "Reconnect" : "Connect"} - </button> - <Show when={detail()?.auth === "authenticated" || detail()?.auth === "expired"}> - <button - type="button" - class="rounded-[4px] px-2 py-1 text-11-medium text-text-weak hover:text-text-strong" + <div class="connectors-row__actions"> + <Show when={config.type === "remote" && config.oauth !== false}> + <button + type="button" + class="connectors-action" + disabled={busy()} + onClick={() => void authenticate(name)} + > + {detail()?.auth === "authenticated" ? "Reconnect" : "Connect"} + </button> + </Show> + <IconButton + icon="edit" + variant="ghost" + disabled={busy()} + aria-label={`Edit ${name}`} + onClick={() => editConnector(name, config)} + /> + <Switch + checked={config.enabled !== false} disabled={busy()} - onClick={() => void disconnectAuth(name)} + onChange={(v) => void toggle(name, v)} + hideLabel > - Disconnect - </button> - </Show> + {name} + </Switch> + <IconButton + icon={expanded() === name ? "chevron-down" : "chevron-right"} + variant="ghost" + aria-expanded={expanded() === name} + aria-label={expanded() === name ? `Hide ${name} details` : `Show ${name} details`} + onClick={() => setExpanded((value) => (value === name ? undefined : name))} + /> + </div> + </div> + <Show when={expanded() === name}> + <div class="connectors-details"> + <ConnectorInspection detail={detail()} /> + <div class="connectors-details__actions"> + <Show when={detail()?.auth === "authenticated" || detail()?.auth === "expired"}> + <button + type="button" + class="connectors-detail-action" + disabled={busy()} + onClick={() => void disconnectAuth(name)} + > + Disconnect OAuth + </button> + </Show> + <button + type="button" + class="connectors-detail-action" + disabled={busy()} + onClick={() => editConnector(name, config)} + > + Edit configuration + </button> + <button + type="button" + class="connectors-detail-action connectors-detail-action--danger" + disabled={busy()} + onClick={() => void remove(name)} + > + Remove connector + </button> + </div> + </div> </Show> - <IconButton - icon="chevron-down" - variant="ghost" - aria-label={ - expanded() === name ? "Hide discovered capabilities" : "Show discovered capabilities" - } - onClick={() => setExpanded((value) => (value === name ? undefined : name))} - /> - <IconButton - icon="edit" - variant="ghost" - disabled={busy()} - aria-label="Edit" - onClick={() => editConnector(name, config)} - /> - <IconButton - icon="trash" - variant="ghost" - disabled={busy()} - aria-label="Remove" - onClick={() => void remove(name)} - /> - <Switch checked={config.enabled !== false} onChange={(v) => void toggle(name, v)} hideLabel> - {name} - </Switch> - </div> - <Show when={expanded() === name}> - <ConnectorInspection detail={detail()} /> - </Show> - </Row> - ) - }} - </For> - </Card> - <button - type="button" - class="self-start text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 mt-1" - disabled={busy()} - onClick={() => void refresh()} - > - <Icon name="enter" size="small" /> Refresh status - </button> - </div> + </article> + ) + }} + </For> + </div> + <button + type="button" + class="connectors-refresh" + disabled={busy()} + onClick={() => void refresh().catch(() => undefined)} + > + <Icon name="refresh" size="small" /> Refresh status + </button> + </section> + </Show> </Show> - </Show> - </PanelBody> + </PanelBody> + </div> </PanelScroll> ) } -// ── form ────────────────────────────────────────────────────────────────── - -interface FormState { - name: string - type: McpType - command: string - url: string - env: string - headers: string - oauth: OAuthMode - clientId: string - clientSecret: string - scope: string - timeout: string - previous?: ConfiguredMcp -} - -function blankForm(type: McpType): FormState { - return { - name: "", - type, - command: "", - url: "", - env: "", - headers: "", - oauth: "auto", - clientId: "", - clientSecret: "", - scope: "", - timeout: "", - } -} - -function formFromConfig(name: string, config: ConfiguredMcp): FormState { - const base = blankForm(config.type) - base.name = name - base.previous = config - base.timeout = config.timeout ? String(config.timeout) : "" - if (config.type === "local") { - base.command = formatConnectorCommand(config.command) - base.env = config.environment ? JSON.stringify(maskRecord(config.environment), null, 2) : "" - return base - } - base.url = config.url - base.headers = config.headers ? JSON.stringify(maskRecord(config.headers), null, 2) : "" - if (config.oauth === false) base.oauth = "off" - else if (config.oauth && "clientId" in config.oauth && config.oauth.clientId) { - base.oauth = "client" - base.clientId = config.oauth.clientId - base.clientSecret = config.oauth.clientSecret ? MASK : "" - base.scope = config.oauth.scope ?? "" - } else base.oauth = "auto" - return base -} - -function maskRecord(value: Record<string, string>) { - return Object.fromEntries(Object.keys(value).map((key) => [key, MASK])) -} - -function maskConfig(value: ConfiguredMcp): ConfiguredMcp { - if (value.type === "local") { - return { - ...value, - environment: value.environment ? maskRecord(value.environment) : undefined, - } - } - return { - ...value, - headers: value.headers ? maskRecord(value.headers) : undefined, - oauth: - value.oauth && typeof value.oauth === "object" - ? { - ...value.oauth, - clientSecret: value.oauth.clientSecret ? MASK : undefined, - } - : value.oauth, - } -} - -function restoreRecord(value: Record<string, string> | undefined, previous: Record<string, string> | undefined) { - if (!value) return undefined - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => { - if (entry !== MASK) return [key, entry] - const stored = previous?.[key] - if (stored === undefined) throw new Error(`Replace the masked value for ${key} before saving`) - return [key, stored] - }), - ) -} - -function parseRecord(text: string, label: string) { - const trimmed = text.trim() - if (!trimmed) return undefined - const parsed = JSON.parse(trimmed) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must be a JSON object`) - for (const [k, v] of Object.entries(parsed)) - if (typeof v !== "string") throw new Error(`${label}.${k} must be a string`) - return parsed as Record<string, string> -} - -function buildConfig(state: FormState): ConfiguredMcp { - const timeout = state.timeout.trim() ? Number(state.timeout) : undefined - if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0)) { - throw new Error("Timeout must be a positive whole number of milliseconds") - } - const enabled = state.previous?.enabled - if (state.type === "local") { - const command = parseConnectorCommand(state.command) - if (command.length === 0) throw new Error("Command is required") - const previous = state.previous?.type === "local" ? state.previous : undefined - const environment = restoreRecord(parseRecord(state.env, "Environment"), previous?.environment) - return { - type: "local", - command, - ...(environment ? { environment } : {}), - ...(enabled === false ? { enabled } : {}), - ...(timeout ? { timeout } : {}), - } - } - if (!URL.canParse(state.url.trim())) throw new Error("Remote URL is invalid") - const previous = state.previous?.type === "remote" ? state.previous : undefined - const headers = restoreRecord(parseRecord(state.headers, "Headers"), previous?.headers) - const oauth = typeof previous?.oauth === "object" ? previous.oauth : undefined - const secret = state.clientSecret.trim() === MASK ? oauth?.clientSecret : state.clientSecret.trim() - return { - type: "remote", - url: state.url.trim(), - ...(headers ? { headers } : {}), - ...(enabled === false ? { enabled } : {}), - ...(timeout ? { timeout } : {}), - ...(state.oauth === "off" - ? { oauth: false } - : state.oauth === "client" - ? { - oauth: { - clientId: state.clientId.trim(), - ...(secret ? { clientSecret: secret } : {}), - ...(state.scope.trim() ? { scope: state.scope.trim() } : {}), - }, - } - : { oauth: {} }), - } -} - function ConnectorForm(props: { - state: FormState + state: ConnectorFormState editing: boolean busy: boolean - onChange: (s: FormState) => void + onChange: (s: ConnectorFormState) => void onSave: () => void onCancel: () => void }) { - const set = <K extends keyof FormState>(key: K, value: FormState[K]) => + const set = <K extends keyof ConnectorFormState>(key: K, value: ConnectorFormState[K]) => props.onChange({ ...props.state, [key]: value }) return ( - <div class="flex flex-col gap-4"> + <section class="settings-section connectors-form-section"> <SectionLabel label={props.editing ? "Edit connector" : `Add ${props.state.type} connector`} /> - <div class="flex flex-col gap-4 p-5 border border-border-weak-base rounded-[4px] bg-surface-base/40"> - <FormField - label="Name" - value={props.state.name} - onInput={(v) => set("name", v)} - placeholder="linear, filesystem…" - /> - <Show - when={props.state.type === "remote"} - fallback={ - <> + <div class="connectors-form"> + <div class="connectors-form__lead"> + <div class="connectors-identity" data-kind={props.state.type === "remote" ? "cloud" : "console"}> + <Icon name={props.state.type === "remote" ? "cloud" : "console"} size="small" /> + </div> + <div> + <strong>{props.state.type === "remote" ? "Hosted MCP server" : "Local MCP process"}</strong> + <p> + {props.state.type === "remote" + ? "Connect over HTTPS and authenticate with OAuth or headers." + : "Launch a trusted command and pass environment values locally."} + </p> + </div> + </div> + <div class="connectors-form__grid"> + <div class="connectors-form__field"> + <FormField + label="Name" + value={props.state.name} + onInput={(v) => set("name", v)} + placeholder="linear, filesystem…" + /> + </div> + <div class="connectors-form__field"> + <FormField + label="Request timeout (ms)" + value={props.state.timeout} + onInput={(v) => set("timeout", v)} + mono + placeholder="5000" + /> + </div> + <Show + when={props.state.type === "remote"} + fallback={ + <> + <div class="connectors-form__field" data-span="full"> + <FormField + label="Command" + value={props.state.command} + onInput={(v) => set("command", v)} + mono + placeholder="npx -y @modelcontextprotocol/server-filesystem ." + /> + </div> + <div class="connectors-form__field" data-span="full"> + <FormField + label="Environment (JSON)" + value={props.state.env} + onInput={(v) => set("env", v)} + multiline + mono + placeholder={'{ "TOKEN": "..." }'} + /> + </div> + <Show when={props.editing && props.state.env}> + <p class="connectors-form__hint"> + Stored values are masked. Keep the mask to preserve a value, replace it to update, or remove its key + to delete it. + </p> + </Show> + </> + } + > + <div class="connectors-form__field" data-span="full"> <FormField - label="Command" - value={props.state.command} - onInput={(v) => set("command", v)} + label="URL" + value={props.state.url} + onInput={(v) => set("url", v)} mono - placeholder="npx -y @modelcontextprotocol/server-filesystem ." + placeholder="https://mcp.example.com/mcp" /> + </div> + <label class="connectors-form__field connectors-form__select"> + <span>OAuth</span> + <select + value={props.state.oauth} + class="settings-field" + onInput={(e) => set("oauth", e.currentTarget.value as OAuthMode)} + > + <option value="auto">Automatic registration</option> + <option value="client">Pre-registered client</option> + <option value="off">No OAuth</option> + </select> + </label> + <div class="connectors-form__field" data-span="full"> <FormField - label="Environment (JSON)" - value={props.state.env} - onInput={(v) => set("env", v)} + label="Headers (JSON)" + value={props.state.headers} + onInput={(v) => set("headers", v)} multiline mono - placeholder={'{ "TOKEN": "..." }'} + placeholder={'{ "Authorization": "Bearer ..." }'} /> - <Show when={props.editing && props.state.env}> - <p class="text-11-regular text-text-weak"> - Stored values are masked. Keep the mask to preserve a value, replace it to update, or remove its key - to delete it. - </p> - </Show> - </> - } - > - <FormField - label="URL" - value={props.state.url} - onInput={(v) => set("url", v)} - mono - placeholder="https://mcp.example.com/mcp" - /> - <label class="flex flex-col gap-1.5"> - <span class="text-12-medium text-text-strong">OAuth</span> - <select - value={props.state.oauth} - class="h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong outline-none focus:border-border-strong-base" - onInput={(e) => set("oauth", e.currentTarget.value as OAuthMode)} - > - <option value="auto">Auto (dynamic registration)</option> - <option value="client">Pre-registered client</option> - <option value="off">Off</option> - </select> - </label> - <FormField - label="Headers (JSON)" - value={props.state.headers} - onInput={(v) => set("headers", v)} - multiline - mono - placeholder={'{ "Authorization": "Bearer ..." }'} - /> - <Show when={props.editing && props.state.headers}> - <p class="text-11-regular text-text-weak"> - Stored header values are masked. Keep the mask to preserve a value, replace it to update, or remove its - key to delete it. - </p> - </Show> - <Show when={props.state.oauth === "client"}> - <FormField label="Client ID" value={props.state.clientId} onInput={(v) => set("clientId", v)} mono /> - <FormField - label="Client secret" - value={props.state.clientSecret} - onInput={(v) => set("clientSecret", v)} - mono - /> - <FormField label="Scope" value={props.state.scope} onInput={(v) => set("scope", v)} mono /> + </div> + <Show when={props.editing && props.state.headers}> + <p class="connectors-form__hint"> + Stored header values are masked. Keep the mask to preserve a value, replace it to update, or remove its + key to delete it. + </p> + </Show> + <Show when={props.state.oauth === "client"}> + <div class="connectors-form__field"> + <FormField label="Client ID" value={props.state.clientId} onInput={(v) => set("clientId", v)} mono /> + </div> + <div class="connectors-form__field"> + <FormField + label="Client secret" + value={props.state.clientSecret} + onInput={(v) => set("clientSecret", v)} + mono + /> + </div> + <div class="connectors-form__field" data-span="full"> + <FormField label="Scope" value={props.state.scope} onInput={(v) => set("scope", v)} mono /> + </div> + </Show> </Show> - </Show> - <FormField - label="Request timeout (ms)" - value={props.state.timeout} - onInput={(v) => set("timeout", v)} - mono - placeholder="5000" - /> - <div class="flex items-center gap-2"> + </div> + <div class="connectors-form__actions"> <FormButton label={props.busy ? "Saving…" : props.editing ? "Save connector" : "Add connector"} disabled={props.busy} @@ -680,7 +611,7 @@ function ConnectorForm(props: { <FormButton label="Cancel" variant="ghost" onClick={props.onCancel} disabled={props.busy} /> </div> </div> - </div> + </section> ) } @@ -695,24 +626,18 @@ function ConnectorInspection(props: { detail?: McpInspection }) { return [...status, ...Object.values(props.detail.errors).filter((error) => error !== undefined)] } return ( - <div class="basis-full w-full border-t border-border-weak-base pt-3 mt-1 flex flex-col gap-3"> - <Show when={props.detail} fallback={<span class="text-12-regular text-text-weak">Inspecting connector…</span>}> + <div class="connectors-inspection"> + <Show when={props.detail} fallback={<span class="connectors-inspection__loading">Inspecting connector…</span>}> {(detail) => ( <> <Show when={failures().length > 0}> - <div - class="rounded-[4px] px-3 py-2" - style={{ - color: "var(--color-error)", - border: "1px solid var(--color-error-muted)", - background: "color-mix(in srgb, var(--color-error) 8%, transparent)", - }} - > + <div role="alert" class="settings-alert" data-tone="critical" data-stacked="true"> <For each={failures()}>{(error) => <p class="text-12-regular break-words">{error}</p>}</For> </div> </Show> - <div class="grid grid-cols-1 gap-3 sm:grid-cols-3"> + <div class="connectors-inspection__grid"> <CapabilityList + icon="settings-gear" title="Tools" empty="No tools reported" items={detail().tools.map((tool) => ({ @@ -721,6 +646,7 @@ function ConnectorInspection(props: { detail?: McpInspection }) { }))} /> <CapabilityList + icon="folder" title="Resources" empty="No resources reported" items={detail().resources.map((resource) => ({ @@ -729,6 +655,7 @@ function ConnectorInspection(props: { detail?: McpInspection }) { }))} /> <CapabilityList + icon="speech-bubble" title="Prompts" empty="No prompts reported" items={detail().prompts.map((prompt) => ({ @@ -744,20 +671,29 @@ function ConnectorInspection(props: { detail?: McpInspection }) { ) } -function CapabilityList(props: { title: string; empty: string; items: Array<{ name: string; description?: string }> }) { +function CapabilityList(props: { + icon: "folder" | "settings-gear" | "speech-bubble" + title: string + empty: string + items: Array<{ name: string; description?: string }> +}) { return ( - <section class="min-w-0"> - <h3 class="text-11-medium tracking-[0.01em] text-text-weak mb-1.5">{props.title}</h3> - <Show when={props.items.length > 0} fallback={<p class="text-11-regular text-text-weak/70">{props.empty}</p>}> - <ul class="flex flex-col gap-1.5"> + <section class="connectors-capability"> + <header> + <Icon name={props.icon} size="small" /> + <h3>{props.title}</h3> + <span>{props.items.length}</span> + </header> + <Show when={props.items.length > 0} fallback={<p class="connectors-capability__empty">{props.empty}</p>}> + <ul> <For each={props.items}> {(item) => ( - <li class="min-w-0"> - <p class="text-12-medium text-text-strong truncate" title={item.name}> + <li> + <p class="connectors-capability__name" title={item.name}> {item.name} </p> <Show when={item.description}> - <p class="text-11-regular text-text-weak line-clamp-2">{item.description}</p> + <p class="connectors-capability__description">{item.description}</p> </Show> </li> )} diff --git a/frontend/workspace/src/components/settings/CredentialServices.tsx b/frontend/workspace/src/components/settings/CredentialServices.tsx index 40db92ee..9c350bb8 100644 --- a/frontend/workspace/src/components/settings/CredentialServices.tsx +++ b/frontend/workspace/src/components/settings/CredentialServices.tsx @@ -1,9 +1,13 @@ import { Button } from "@synsci/ui/button" +import { Icon } from "@synsci/ui/icon" +import { useDialog } from "@synsci/ui/context/dialog" import { type Component, type JSX, For, Show, createMemo, createSignal, onMount } from "solid-js" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" import { settingsApi } from "./api" import { ProviderLogo } from "./ProviderLogo" +import { customCredentialIdentity } from "./custom-credential" type Field = { name: string @@ -33,11 +37,13 @@ export const CredentialServices: Component<{ }> = (props) => { const sdk = useGlobalSDK() const platform = usePlatform() + const dialog = useDialog() const [services, setServices] = createSignal<Service[]>([]) const [error, setError] = createSignal<string>() const [editing, setEditing] = createSignal<string>() const [values, setValues] = createSignal<Record<string, string>>({}) const [saving, setSaving] = createSignal(false) + const [loading, setLoading] = createSignal(true) const [custom, setCustom] = createSignal(false) const [name, setName] = createSignal("") const [field, setField] = createSignal("api_key") @@ -52,6 +58,7 @@ export const CredentialServices: Component<{ const count = createMemo(() => items().filter((service) => service.connected).length) const load = async () => { + setLoading(true) setError(undefined) const result = await settingsApi<{ services: Service[] }>( sdk.url, @@ -62,11 +69,13 @@ export const CredentialServices: Component<{ return undefined }) if (result) setServices(result.services) + setLoading(false) } onMount(() => void load()) const open = (id: string) => { + if (saving()) return setValues({}) setEditing(editing() === id ? undefined : id) } @@ -104,31 +113,43 @@ export const CredentialServices: Component<{ } const remove = async (service: Service) => { - if (!window.confirm(`Remove the saved ${service.label} credentials from this machine?`)) return - setError(undefined) - const result = await settingsApi<{ services: Service[] }>( - sdk.url, - platform.fetch ?? fetch, - `/settings/credentials/${encodeURIComponent(service.id)}`, - { method: "DELETE" }, - ).catch((cause) => { - setError(cause instanceof Error ? cause.message : String(cause)) - return undefined + if (saving()) return + const confirmed = await confirmDialog(dialog, { + title: `Remove ${service.label} credentials?`, + message: "This removes the saved credentials from this machine. You can connect the service again at any time.", + confirmLabel: "Remove credentials", + danger: true, }) - if (result) setServices(result.services) + if (!confirmed) return + setSaving(true) + setError(undefined) + try { + const result = await settingsApi<{ services: Service[] }>( + sdk.url, + platform.fetch ?? fetch, + `/settings/credentials/${encodeURIComponent(service.id)}`, + { method: "DELETE" }, + ).catch((cause) => { + setError(cause instanceof Error ? cause.message : String(cause)) + return undefined + }) + if (result) { + setServices(result.services) + if (editing() === service.id) setEditing(undefined) + } + } finally { + setSaving(false) + } } const add = async () => { - const label = name().trim() const value = secret().trim() - const key = field().trim() || "api_key" - if (!label || !value) return - const slug = label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - if (!slug) return - const saved = await save(`custom:${slug}`, { [key]: value }, label) + const identity = customCredentialIdentity(name(), field()) + if (!value || !identity.ok) { + if (!identity.ok) setError(identity.error) + return + } + const saved = await save(identity.id, { [identity.field]: value }, identity.label) if (!saved) return setCustom(false) setName("") @@ -137,119 +158,172 @@ export const CredentialServices: Component<{ } return ( - <section class="credential-services"> - <div class="settings-section-heading"> - <div> + <section class="credential-services settings-section"> + <div class="settings-section-heading flex-wrap"> + <div class="min-w-0 flex-1 basis-[240px]"> <h3>{props.title}</h3> <p>{props.description}</p> </div> - <span>{count()} connected</span> + <span class="ml-auto shrink-0">{loading() ? "Loading…" : `${count()} saved`}</span> </div> <Show when={error()}> - <div class="settings-error" role="alert"> - {error()} + <div class="settings-alert" data-tone="critical" role="alert"> + <span>{error()}</span> + <button + type="button" + class="settings-inline-action" + disabled={loading() || saving()} + onClick={() => void load()} + > + Retry + </button> </div> </Show> - <div class="settings-list"> - <For each={items()}> - {(service) => ( - <div class="settings-list-item"> - <div class="settings-list-row"> - <ProviderLogo id={service.id} label={service.label} connected={service.connected} /> - <div class="settings-list-copy"> - <strong>{service.label}</strong> - <span>{service.connected ? "Connected and ready" : service.description}</span> - </div> - <div class="settings-list-actions"> - <Show when={service.connected}> - <Button size="small" variant="ghost" onClick={() => void remove(service)}> - Remove - </Button> - </Show> - <Button - size="small" - variant={service.connected ? "secondary" : "primary"} - onClick={() => open(service.id)} - > - {editing() === service.id ? "Cancel" : service.connected ? "Update" : "Connect"} - </Button> - </div> + <Show + when={!loading()} + fallback={ + <div class="settings-card"> + <div class="settings-row text-12-regular text-text-weak">Loading services…</div> + </div> + } + > + <Show + when={items().length > 0} + fallback={ + <div class="settings-card"> + <div class="settings-row"> + <Icon name="providers" size="small" class="shrink-0 text-icon-weak-base" /> + <span class="text-12-regular text-text-weak">No services are available from this server.</span> </div> + </div> + } + > + <div class="settings-list settings-card"> + <For each={items()}> + {(service) => ( + <div class="settings-list-item"> + <div class="settings-list-row"> + <ProviderLogo id={service.id} label={service.label} /> + <div class="settings-list-copy"> + <div class="flex min-w-0 flex-wrap items-center gap-2"> + <strong>{service.label}</strong> + <Show when={service.connected}> + <span class="settings-chip">Saved</span> + </Show> + </div> + <span>{service.connected ? "Encrypted on this machine" : service.description}</span> + </div> + <div class="settings-list-actions ml-auto max-w-full flex-wrap justify-end"> + <Show when={service.connected}> + <button + type="button" + class="settings-icon-action" + disabled={saving()} + aria-label={`Remove ${service.label} credentials`} + title="Remove credentials" + onClick={() => void remove(service)} + > + <Icon name="trash" size="small" /> + </button> + </Show> + <Button size="small" variant="secondary" disabled={saving()} onClick={() => open(service.id)}> + {editing() === service.id ? "Cancel" : service.connected ? "Update" : "Connect"} + </Button> + </div> + </div> - <Show when={editing() === service.id}> - <form - class="credential-form" - onSubmit={(event) => { - event.preventDefault() - void save(service.id) - }} - > - <For each={service.fields}> - {(item) => ( - <label> - <span> - {item.label} - {item.optional ? " (optional)" : ""} - {service.set_fields.includes(item.name) ? " · saved" : ""} - </span> - <Show - when={item.type === "textarea"} - fallback={ - <input - type={item.type === "password" ? "password" : "text"} - autocomplete="off" - spellcheck={false} - value={values()[item.name] ?? ""} - placeholder={ - item.placeholder ?? - (service.set_fields.includes(item.name) ? "Leave blank to keep saved value" : "") + <Show when={editing() === service.id}> + <form + class="credential-form min-w-0" + onSubmit={(event) => { + event.preventDefault() + void save(service.id) + }} + > + <For each={service.fields}> + {(item) => ( + <label> + <span> + {item.label} + {item.optional ? " (optional)" : ""} + {service.set_fields.includes(item.name) ? " · saved" : ""} + </span> + <Show + when={item.type === "textarea"} + fallback={ + <input + type={item.type === "password" ? "password" : "text"} + autocomplete="off" + spellcheck={false} + disabled={saving()} + value={values()[item.name] ?? ""} + placeholder={ + item.placeholder ?? + (service.set_fields.includes(item.name) ? "Leave blank to keep saved value" : "") + } + onInput={(event) => + setValues({ ...values(), [item.name]: event.currentTarget.value }) + } + /> } - onInput={(event) => setValues({ ...values(), [item.name]: event.currentTarget.value })} - /> - } + > + <textarea + autocomplete="off" + spellcheck={false} + disabled={saving()} + value={values()[item.name] ?? ""} + placeholder={ + item.placeholder ?? + (service.set_fields.includes(item.name) ? "Leave blank to keep saved value" : "") + } + onInput={(event) => setValues({ ...values(), [item.name]: event.currentTarget.value })} + /> + </Show> + </label> + )} + </For> + <div class="credential-form-actions max-w-full flex-wrap"> + <Button type="submit" size="small" variant="primary" disabled={saving() || !ready(service)}> + {saving() ? "Saving…" : "Save credential"} + </Button> + <Button + type="button" + size="small" + variant="ghost" + disabled={saving()} + onClick={() => setEditing(undefined)} > - <textarea - autocomplete="off" - spellcheck={false} - value={values()[item.name] ?? ""} - placeholder={ - item.placeholder ?? - (service.set_fields.includes(item.name) ? "Leave blank to keep saved value" : "") - } - onInput={(event) => setValues({ ...values(), [item.name]: event.currentTarget.value })} - /> - </Show> - </label> - )} - </For> - <div class="credential-form-actions"> - <Button type="submit" size="small" variant="primary" disabled={saving() || !ready(service)}> - {saving() ? "Saving…" : "Save credential"} - </Button> - <Button type="button" size="small" variant="ghost" onClick={() => setEditing(undefined)}> - Cancel - </Button> - </div> - </form> - </Show> - </div> - )} - </For> - </div> + Cancel + </Button> + </div> + </form> + </Show> + </div> + )} + </For> + </div> + </Show> + </Show> <Show when={props.custom}> <Show when={custom()} fallback={ - <button class="settings-add-row" type="button" onClick={() => setCustom(true)}> + <button + class="settings-add-row inline-flex items-center gap-1.5" + type="button" + disabled={loading() || saving()} + onClick={() => setCustom(true)} + > + <Icon name="plus" size="small" /> Add custom credential </button> } > <form - class="credential-form credential-form--custom" + class="credential-form credential-form--custom settings-card min-w-0" onSubmit={(event) => { event.preventDefault() void add() @@ -266,17 +340,17 @@ export const CredentialServices: Component<{ placeholder="Paste secret" onInput={setSecret} /> - <p>OpenScience will make this available as SERVICE_NAME_ENVIRONMENT_FIELD.</p> - <div class="credential-form-actions"> + <p class="break-words">OpenScience will make this available as SERVICE_NAME_ENVIRONMENT_FIELD.</p> + <div class="credential-form-actions max-w-full flex-wrap"> <Button type="submit" size="small" variant="primary" - disabled={saving() || !name().trim() || !secret().trim()} + disabled={saving() || !secret().trim() || !customCredentialIdentity(name(), field()).ok} > {saving() ? "Saving…" : "Save credential"} </Button> - <Button type="button" size="small" variant="ghost" onClick={() => setCustom(false)}> + <Button type="button" size="small" variant="ghost" disabled={saving()} onClick={() => setCustom(false)}> Cancel </Button> </div> diff --git a/frontend/workspace/src/components/settings/Credentials.tsx b/frontend/workspace/src/components/settings/Credentials.tsx index 7fb9fd2a..34c608ec 100644 --- a/frontend/workspace/src/components/settings/Credentials.tsx +++ b/frontend/workspace/src/components/settings/Credentials.tsx @@ -1,21 +1,22 @@ import { type Component } from "solid-js" import { CredentialServices } from "./CredentialServices" +import { PanelBody, PanelHeader, PanelScroll } from "./_shared" export const Credentials: Component = () => ( - <div class="flex h-full flex-col overflow-y-auto no-scrollbar"> - <header class="settings-page-header"> - <h2>Credentials</h2> - <p>Bring the rest of your research stack along. Add a service once, and it is ready when your tools need it.</p> - </header> - <div class="settings-page-body"> + <PanelScroll> + <PanelHeader + title="Credentials" + description="Add service credentials once and make them available to the tools that need them." + /> + <PanelBody> <CredentialServices category="integration" title="Integrations" - description="A quiet home for GitHub, OpenAlex, Hugging Face, Weights & Biases, and everything in between." + description="GitHub, OpenAlex, Hugging Face, Weights & Biases, and other research services." custom /> - </div> - </div> + </PanelBody> + </PanelScroll> ) export default Credentials diff --git a/frontend/workspace/src/components/settings/General.test.ts b/frontend/workspace/src/components/settings/General.test.ts new file mode 100644 index 00000000..8a3bb822 --- /dev/null +++ b/frontend/workspace/src/components/settings/General.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { commitPreference } from "./preference-write" + +describe("General preference writes", () => { + test("applies the backend-confirmed value only after the write succeeds", async () => { + const order: string[] = [] + const result = await commitPreference( + async () => { + order.push("write") + return { atlas_enabled: true } + }, + (value) => order.push(`apply:${value.atlas_enabled}`), + ) + + expect(result).toEqual({ ok: true }) + expect(order).toEqual(["write", "apply:true"]) + }) + + test("reports a failed write and leaves current UI state untouched", async () => { + let applyCalls = 0 + const result = await commitPreference( + async () => { + throw new Error("disk is read-only") + }, + () => applyCalls++, + ) + + expect(result).toEqual({ ok: false, error: "disk is read-only" }) + expect(applyCalls).toBe(0) + }) +}) diff --git a/frontend/workspace/src/components/settings/General.tsx b/frontend/workspace/src/components/settings/General.tsx index 4c4479dc..b8013857 100644 --- a/frontend/workspace/src/components/settings/General.tsx +++ b/frontend/workspace/src/components/settings/General.tsx @@ -1,20 +1,25 @@ -// General — Account and Licensing, plus the appearance/theme +// General — account, workspace navigation, and appearance // controls. Everything here is wired to a real endpoint: // • Account → client.account.get / client.account.logout, billing link. // • Licensing → /settings/preferences (real JSON store, persisted to ~/.openscience). -// • Appearance → the extracted AppearanceSections (theme, sounds, updates, …). +// • Appearance → the extracted AppearanceSections (display mode, sounds, updates, …). import { Component, Show, createSignal, onMount, type JSX } from "solid-js" import { Button } from "@synsci/ui/button" +import { Icon, type IconProps } from "@synsci/ui/icon" +import { useDialog } from "@synsci/ui/context/dialog" import { Switch } from "@synsci/ui/switch" import { showToast } from "@synsci/ui/toast" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { URLS } from "@/config/urls" -import { FONT_CODE, FONT_SANS } from "@/styles/tokens" import { AppearanceSections } from "../settings-general" import { settingsApi } from "./api" +import { commitPreference } from "./preference-write" import { productPreferences } from "@/context/product-preferences" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./preference-panels.css" type Account = { session?: boolean @@ -24,7 +29,6 @@ type Account = { } type Preferences = { - intent: "commercial" | "non-commercial" extra_budget_usd: number show_trace: boolean atlas_enabled: boolean @@ -34,6 +38,7 @@ export default function General() { const sdk = useGlobalSDK() const platform = usePlatform() const server = useServer() + const dialog = useDialog() const fetchFn = () => platform.fetch ?? fetch const base = () => server.url @@ -42,6 +47,8 @@ export default function General() { const [prefs, setPrefs] = createSignal<Preferences | undefined>() const [error, setError] = createSignal<string>() const [busy, setBusy] = createSignal(false) + const [preferenceBusy, setPreferenceBusy] = createSignal(false) + const [showAdvanced, setShowAdvanced] = createSignal(false) const loadAccount = async () => { try { @@ -66,16 +73,32 @@ export default function General() { }) const savePref = async (patch: Partial<Preferences>) => { - const next = await settingsApi<Preferences>(base(), fetchFn(), "/settings/preferences", { - method: "PATCH", - body: JSON.stringify(patch), - }) - setPrefs(next) - productPreferences.sync(next) + if (preferenceBusy()) return + setPreferenceBusy(true) + setError(undefined) + const result = await commitPreference( + () => + settingsApi<Preferences>(base(), fetchFn(), "/settings/preferences", { + method: "PATCH", + body: JSON.stringify(patch), + }), + (next) => { + setPrefs(next) + productPreferences.sync(next) + }, + ) + if (!result.ok) setError(result.error) + setPreferenceBusy(false) } const signOut = async () => { - if (!window.confirm("Disconnect this local server from OpenScience?")) return + const confirmed = await confirmDialog(dialog, { + title: "Disconnect this server?", + message: "This signs the local server out of OpenScience. Local projects and files stay on this machine.", + confirmLabel: "Disconnect", + danger: true, + }) + if (!confirmed) return setBusy(true) try { const res = await sdk.client.account.logout() @@ -89,130 +112,129 @@ export default function General() { } } - const plan = () => (account()?.user?.subscription_plan as string | undefined) ?? undefined + const plan = () => { + const value = account()?.user?.subscription_plan as string | undefined + if (!value) return "—" + return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}` + } const org = () => { const u = account()?.user ?? {} return (u.organization ?? u.org ?? u.team ?? u.organization_name) as string | undefined } return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">General</h2> - <p class="text-13-regular text-text-weak">Your account, workspace, licensing, and appearance.</p> - </div> - </div> + <PanelScroll> + <div class="settings-preferences-panel settings-preferences-panel--general"> + <PanelHeader title="General" description="Manage your account and everyday workspace preferences." /> + <PanelBody> + <Show when={error()}> + <div class="settings-alert" data-tone="critical" role="alert"> + {error()} + </div> + </Show> - <div class="settings-page-body"> - <Show when={error()}> - <div - style={{ - "font-family": FONT_SANS, - "font-size": "12px", - color: "var(--color-error)", - border: "1px solid var(--color-error-muted)", - "border-radius": "4px", - padding: "10px 12px", - }} - > - {error()} - </div> - </Show> + {/* Account */} + <Section title="Account" description="Your OpenScience identity and subscription."> + <div class="settings-card settings-preferences-card"> + <Row icon="providers" title="Email"> + <span class="settings-account-value"> + {(account()?.user?.email as string) ?? (account()?.session === false ? "Not connected" : "—")} + </span> + </Row> + <Row icon="star" title="Plan"> + <span class="settings-account-value">{plan()}</span> + </Row> + <Show when={org()}> + <Row icon="home" title="Organization"> + <span class="settings-account-value">{org()}</span> + </Row> + </Show> + <Row icon="bolt" title="Billing" description="Manage your subscription, wallet, and invoices."> + <Button size="small" variant="secondary" onClick={() => platform.openLink(URLS.dashboardBilling)}> + Manage + </Button> + </Row> + <Row icon="link" title="Session" description="Disconnect this machine from OpenScience."> + <Button + size="small" + variant="secondary" + disabled={busy() || account()?.session === false} + onClick={() => void signOut()} + > + Disconnect + </Button> + </Row> + <Show when={account()?.session === false}> + <div class="px-4 py-3"> + <p class="text-12-regular text-text-weak"> + Signed out — run <code class="font-mono text-11-regular">openscience connect login</code> in a + terminal to reconnect this machine. + </p> + </div> + </Show> + </div> + </Section> - {/* Account */} - <Section title="Account" description="Your OpenScience identity and subscription."> - <div class="overflow-hidden rounded-[8px] border border-border-weak-base bg-surface-base/25"> - <Row title="Email"> - <span class="text-13-regular text-text-strong"> - {(account()?.user?.email as string) ?? (account()?.session === false ? "Not connected" : "—")} - </span> - </Row> - <Row title="Plan"> - <span class="text-13-regular text-text-strong capitalize">{plan() ?? "Free"}</span> - </Row> - <Show when={org()}> - <Row title="Organization"> - <span class="text-13-regular text-text-strong">{org()}</span> + <Section title="Navigation" description="Choose which optional research surfaces appear in each project."> + <div class="settings-card settings-preferences-card"> + <Row + icon="branch" + title="Atlas" + description="Show the research map in project navigation. Your map data is never changed." + > + <Switch + hideLabel + checked={prefs()?.atlas_enabled ?? false} + disabled={!prefs() || preferenceBusy()} + onChange={(atlas_enabled) => void savePref({ atlas_enabled })} + > + Show Atlas + </Switch> </Row> - </Show> - <Row title="Billing" description="Manage your subscription, wallet, and invoices."> - <Button size="small" variant="secondary" onClick={() => platform.openLink(URLS.dashboardBilling)}> - Manage - </Button> - </Row> - <Row title="Session" description="Disconnect this machine from OpenScience."> - <Button - size="small" - variant="secondary" - disabled={busy() || account()?.session === false} - onClick={() => void signOut()} + <Row + icon="activity" + title="Trace" + description="Show the local time, cost, and activity trace in session navigation." > - Disconnect - </Button> - </Row> - <Show when={account()?.session === false}> - <div class="px-4 py-3"> - <p class="text-12-regular text-text-weak"> - Signed out — run{" "} - <code style={{ "font-family": FONT_CODE, "font-size": "11px" }}>openscience connect login</code> in a - terminal to reconnect this machine. - </p> - </div> - </Show> - </div> - </Section> - - {/* Licensing */} - <Section title="Licensing" description="How you intend to use outputs from OpenScience."> - <div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5"> - <IntentCard - active={prefs()?.intent === "non-commercial"} - title="Non-commercial" - body="Research, evaluation, and personal projects." - onClick={() => void savePref({ intent: "non-commercial" })} - /> - <IntentCard - active={prefs()?.intent === "commercial"} - title="Commercial" - body="Use in a product or for-profit work." - onClick={() => void savePref({ intent: "commercial" })} - /> - </div> - </Section> + <Switch + hideLabel + checked={prefs()?.show_trace ?? false} + disabled={!prefs() || preferenceBusy()} + onChange={(show_trace) => void savePref({ show_trace })} + > + Show Trace + </Switch> + </Row> + </div> + </Section> - <Section title="Navigation" description="Choose which optional research surfaces appear in each project."> - <div class="overflow-hidden rounded-[8px] border border-border-weak-base bg-surface-base/25"> - <Row - title="Atlas" - description="Show the research map in project navigation. Your map data is never changed." - > - <Switch - hideLabel - checked={prefs()?.atlas_enabled ?? false} - disabled={!prefs()} - onChange={(atlas_enabled) => void savePref({ atlas_enabled })} - > - Show Atlas - </Switch> - </Row> - <Row title="Trace" description="Show the local time, cost, and activity trace in session navigation."> - <Switch - hideLabel - checked={prefs()?.show_trace ?? false} - disabled={!prefs()} - onChange={(show_trace) => void savePref({ show_trace })} + {/* Keep frequently used display and notification controls visible; + disclose sound and update preferences only when requested. */} + <div class="settings-disclosure-group"> + <div class="settings-general-extras" data-expanded={showAdvanced() ? "true" : "false"}> + <AppearanceSections /> + </div> + <div class="settings-disclosure-footer"> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + aria-expanded={showAdvanced()} + onClick={() => setShowAdvanced((value) => !value)} > - Show Trace - </Switch> - </Row> + <Icon + name="chevron-down" + size="small" + classList={{ "rotate-180": showAdvanced() }} + aria-hidden="true" + /> + {showAdvanced() ? "Show fewer settings" : "Show sound and update settings"} + </button> + </div> </div> - </Section> - - {/* Appearance / theme / notifications / sounds / updates */} - <AppearanceSections /> + </PanelBody> </div> - </div> + </PanelScroll> ) } @@ -220,56 +242,19 @@ function message(err: unknown) { return err instanceof Error ? err.message : String(err) } -const Section: Component<{ title: string; description?: string; children: JSX.Element }> = (props) => ( - <div class="flex flex-col gap-3"> - <div class="flex flex-col gap-0.5"> - <h3 class="text-14-medium text-text-strong tracking-[-0.01em]">{props.title}</h3> - <Show when={props.description}> - <p class="text-12-regular text-text-weak">{props.description}</p> - </Show> - </div> - {props.children} - </div> -) - -const Row: Component<{ title: string; description?: string; children: JSX.Element }> = (props) => ( - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5 border-b border-border-weak-base last:border-none"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-14-medium text-text-strong">{props.title}</span> +const Row: Component<{ icon: IconProps["name"]; title: string; description?: string; children: JSX.Element }> = ( + props, +) => ( + <div class="settings-row settings-preference-row justify-between"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name={props.icon} size="small" /> + </span> + <div class="settings-row-copy"> + <strong>{props.title}</strong> <Show when={props.description}> - <span class="text-12-regular text-text-weak">{props.description}</span> + <span>{props.description}</span> </Show> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="ml-auto max-w-full flex-shrink-0">{props.children}</div> </div> ) - -const IntentCard: Component<{ active: boolean; title: string; body: string; onClick: () => void }> = (props) => ( - <button - type="button" - onClick={props.onClick} - style={{ - all: "unset", - cursor: "pointer", - display: "flex", - "flex-direction": "column", - gap: "5px", - padding: "14px 16px", - "border-radius": "4px", - border: "1px solid var(--color-border)", - "box-shadow": props.active ? "inset 0 0 0 1px var(--color-text-interactive-base, var(--color-text))" : "none", - background: props.active ? "var(--color-surface-interactive-weak, var(--color-accent-subtle))" : "transparent", - transition: "border-color 120ms, box-shadow 120ms, background 120ms", - }} - > - <div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}> - <span class="text-14-medium text-text-strong">{props.title}</span> - <Show when={props.active}> - <span style={{ "font-family": FONT_SANS, "font-size": "11px", color: "var(--color-text-muted)" }}>Active</span> - </Show> - </div> - <span class="text-12-regular text-text-weak" style={{ "line-height": 1.5 }}> - {props.body} - </span> - </button> -) diff --git a/frontend/workspace/src/components/settings/ManagedInference.test.ts b/frontend/workspace/src/components/settings/ManagedInference.test.ts index f4ae6390..85fe9ad8 100644 --- a/frontend/workspace/src/components/settings/ManagedInference.test.ts +++ b/frontend/workspace/src/components/settings/ManagedInference.test.ts @@ -1,25 +1,10 @@ import { expect, test } from "bun:test" import { commitBilling } from "./ManagedInference" -// commitBilling is the write-then-refresh ordering ManagedInference.update() -// depends on: a mode switch must land in the provider catalog without a -// reload, which only happens if refreshProviders() runs strictly after the -// billing write resolves — and never runs at all on a failed write. These -// tests exercise the real exported function with plain async stand-ins for -// the SDK call and refreshProviders(), asserted on call order rather than -// timing, so no live backend or SDK/globalSync mocking is needed. - -// A macrotask hop (setTimeout, not a bare microtask) a refresh stand-in must -// cross before it's "done". An `async () => order.push(...)` stand-in with no -// internal await runs its body synchronously the instant it's *called* — -// whether or not the caller awaits the returned promise — so it can't tell a -// real `await refresh()` apart from a dropped one; both produce the same -// order array. Forcing a real event-loop turn here means the "refresh" -// entry only lands if commitBilling's returned promise genuinely waited for -// it, which is the actual property under test. -const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) - -test("refreshes the provider catalog only after the write resolves and its data is applied, and does not resolve until refresh completes", async () => { +// The provider catalog is intentionally not part of this helper. It is a +// multi-megabyte follow-up synchronization; including it here used to keep all +// three routing buttons disabled long after the small billing write had saved. +test("resolves as soon as the billing write is applied", async () => { const order: string[] = [] const applied: number[] = [] @@ -32,21 +17,16 @@ test("refreshes the provider catalog only after the write resolves and its data order.push("apply") applied.push(data.llm.length) }, - async () => { - await tick() - order.push("refresh") - }, ) expect(ok).toBe(true) - expect(order).toEqual(["write", "apply", "refresh"]) + expect(order).toEqual(["write", "apply"]) expect(applied).toEqual(["managed".length]) }) -test("does not refresh when the write comes back without data", async () => { +test("does not apply when the write comes back without data", async () => { const order: string[] = [] let applyCalls = 0 - let refreshCalls = 0 const ok = await commitBilling<{ llm: string }>( async () => { @@ -54,49 +34,23 @@ test("does not refresh when the write comes back without data", async () => { return {} }, () => applyCalls++, - async () => { - refreshCalls++ - }, ) expect(ok).toBe(false) expect(order).toEqual(["write"]) expect(applyCalls).toBe(0) - expect(refreshCalls).toBe(0) }) -test("propagates a write rejection without applying data or refreshing", async () => { +test("propagates a write rejection without applying data", async () => { let applyCalls = 0 - let refreshCalls = 0 const rejection = commitBilling<{ llm: string }>( async () => { throw new Error("network down") }, () => applyCalls++, - async () => { - refreshCalls++ - }, ) await expect(rejection).rejects.toThrow("network down") expect(applyCalls).toBe(0) - expect(refreshCalls).toBe(0) -}) - -test("propagates a refresh rejection to the caller instead of swallowing it", async () => { - // If commitBilling ever called refresh() without awaiting it, this - // rejection would never reach the caller's .catch(fail) — it would surface - // as an unhandled promise rejection instead, and commitBilling would - // resolve `true` as if the refresh had succeeded. - const rejection = commitBilling<{ llm: string }>( - async () => ({ data: { llm: "managed" } }), - () => {}, - async () => { - await tick() - throw new Error("refresh failed") - }, - ) - - await expect(rejection).rejects.toThrow("refresh failed") }) diff --git a/frontend/workspace/src/components/settings/ManagedInference.tsx b/frontend/workspace/src/components/settings/ManagedInference.tsx index 6a59c47d..34cccdd0 100644 --- a/frontend/workspace/src/components/settings/ManagedInference.tsx +++ b/frontend/workspace/src/components/settings/ManagedInference.tsx @@ -1,6 +1,6 @@ import type { SettingsBillingGetResponse } from "@synsci/sdk/v2/client" import { Button } from "@synsci/ui/button" -import { For, Show, createSignal, onCleanup, onMount } from "solid-js" +import { For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { URLS } from "@/config/urls" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -36,23 +36,15 @@ const MODES: { value: Mode; title: string; body: string }[] = [ const money = (value: number) => `$${value.toFixed(value >= 100 ? 0 : 2)}` /** - * The write-then-refresh ordering a mode switch depends on: the billing write - * must resolve and its data must be applied before the provider catalog is - * refreshed, and the refresh must not run at all when the write comes back - * without data (a failed save). Pulled out of `update()` and parameterized - * over `write`/`apply`/`refresh` so the ordering is unit-testable with plain - * async functions standing in for the SDK call and `refreshProviders()` — - * no live backend, no mocking `sdk`/`globalSync`. + * Persist and apply the small billing response independently of the much larger + * provider-catalog refresh. The mode control must become usable as soon as the + * save finishes; reloading every provider/model is follow-up synchronization, + * not part of the button's acknowledgement path. */ -export async function commitBilling<T>( - write: () => Promise<{ data?: T }>, - apply: (data: T) => void, - refresh: () => Promise<void>, -): Promise<boolean> { +export async function commitBilling<T>(write: () => Promise<{ data?: T }>, apply: (data: T) => void): Promise<boolean> { const result = await write() if (!result.data) return false apply(result.data) - await refresh() return true } @@ -61,8 +53,10 @@ export function ManagedInference(props: { onError?: (error: string | undefined) const globalSync = useGlobalSync() const platform = usePlatform() const [wallet, setWallet] = createSignal<Wallet>() - const [billing, setBilling] = createSignal<SettingsBillingGetResponse>() + const [mode, setMode] = createSignal<Mode>(globalSync.data.config.billing?.llm ?? null) const [busy, setBusy] = createSignal(false) + const [refreshing, setRefreshing] = createSignal(false) + const selected = createMemo(() => MODES.find((item) => item.value === mode()) ?? MODES[2]) const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)) const fail = (error: unknown) => { @@ -76,7 +70,10 @@ export function ManagedInference(props: { onError?: (error: string | undefined) sdk.client.settings.billing .get() .then((result) => { - if (result.data) return setBilling(result.data) + if (result.data) { + if (!busy()) setMode(result.data.llm) + return + } fail("Couldn't load managed inference settings.") }) .catch(fail) @@ -86,29 +83,42 @@ export function ManagedInference(props: { onError?: (error: string | undefined) } const update = (value: Mode) => { if (busy()) return + const previous = mode() + // Immediate visual acknowledgement: the network write may include account + // synchronization, but the pressed state should never wait on that work. + setMode(value) setBusy(true) props.onError?.(undefined) - // apply() only runs once the write has data, so a rejection past that - // point is the catalog refresh failing, not the save — the same split - // credentialChange draws for the other credential panels. A rejection - // before apply() is a genuine save failure and keeps the plain wording. - let saved = false void commitBilling( () => sdk.client.settings.billing.update({ llm: value }), (data) => { - saved = true - setBilling(data) + setMode(data.llm) }, - () => globalSync.refreshProviders(), ) .then((ok) => { - if (!ok) fail("Couldn't save managed inference settings.") + if (!ok) { + setMode(previous) + fail("Couldn't save managed inference settings.") + return + } + + // Re-enable the mode controls before the multi-scope provider catalog + // reload. This fetch can be several megabytes and must not make the + // already-saved setting feel stuck. + setBusy(false) + setRefreshing(true) + void globalSync + .refreshProviders() + .catch((error) => + props.onError?.( + `Managed inference settings saved, but the model list could not be reloaded (${reason(error)}). It will catch up on the next refresh.`, + ), + ) + .finally(() => setRefreshing(false)) }) .catch((error) => { - if (!saved) return fail(error) - props.onError?.( - `Managed inference settings saved, but the model list could not be reloaded (${reason(error)}). It will catch up on the next refresh.`, - ) + setMode(previous) + fail(error) }) .finally(() => setBusy(false)) } @@ -134,55 +144,74 @@ export function ManagedInference(props: { onError?: (error: string | undefined) value === "managed" && wallet() !== undefined && (!wallet()!.signedIn || !wallet()!.managedSupported) return ( - <div class="flex flex-col gap-3"> - <div class="flex min-h-12 flex-wrap items-center gap-x-5 gap-y-2 rounded-[4px] border border-border-weak-base bg-surface-base/40 px-4 py-3"> - <div class="flex min-w-[150px] flex-1 flex-col gap-0.5"> - <span class="text-12-regular text-text-weak">OpenScience credits</span> - <Show when={wallet()} fallback={<span class="text-13-medium text-text-weak">Checking account…</span>}> - <span class="text-13-medium text-text-strong"> - {wallet()!.signedIn - ? wallet()!.balanceUsd >= 0 - ? `${money(wallet()!.balanceUsd)} available` - : "Balance unavailable" - : "Not signed in"} - </span> - </Show> + <div class="models-inference"> + <div class="settings-card settings-account-summary models-account-summary"> + <div class="settings-row models-compact-row"> + <div class="models-account-summary__identity"> + <div class="flex min-w-0 flex-col gap-0.5"> + <span class="text-12-regular text-text-weak">OpenScience credits</span> + <Show when={wallet()} fallback={<span class="text-13-medium text-text-weak">Checking account…</span>}> + <span class="text-13-medium text-text-strong"> + {wallet()!.signedIn + ? wallet()!.balanceUsd >= 0 + ? `${money(wallet()!.balanceUsd)} available` + : "Balance unavailable" + : "Not signed in"} + </span> + </Show> + </div> + </div> + <span class="models-row-action"> + <Button + class="settings-panel-action models-secondary-action" + size="small" + variant="secondary" + onClick={() => platform.openLink(URLS.dashboardBilling)} + > + Add funds + </Button> + </span> </div> - <Button size="small" variant="secondary" onClick={() => platform.openLink(URLS.dashboardBilling)}> - Add funds - </Button> </div> - <div class="grid grid-cols-1 gap-2 sm:grid-cols-3"> - <For each={MODES}> - {(mode) => ( - <button - type="button" - aria-pressed={billing() !== undefined && billing()!.llm === mode.value} - disabled={busy() || billing() === undefined || unsupported(mode.value)} - class="flex min-h-[92px] flex-col gap-1 rounded-[4px] border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50" - classList={{ - "border-border-strong-base bg-surface-raised-base": - billing() !== undefined && billing()!.llm === mode.value, - "border-border-weak-base bg-surface-base/40 hover:bg-surface-raised-base": - billing() === undefined || billing()!.llm !== mode.value, - }} - title={ - unsupported(mode.value) - ? "Managed inference requires a signed-in account with managed billing enabled" - : undefined - } - onClick={() => update(mode.value)} - > - <span class="text-13-medium text-text-strong">{mode.title}</span> - <span class="text-12-regular leading-relaxed text-text-weak">{mode.body}</span> - </button> - )} - </For> + <div class="models-routing" aria-label="Inference routing"> + <div class="models-routing__options" role="group" aria-label="Inference routing mode"> + <For each={MODES}> + {(option) => ( + <button + type="button" + aria-pressed={mode() === option.value} + aria-describedby={`managed-inference-${option.value ?? "automatic"}-description`} + aria-busy={busy()} + disabled={busy() || unsupported(option.value)} + class="models-routing__option" + title={ + unsupported(option.value) + ? "Managed inference requires a signed-in account with managed billing enabled" + : undefined + } + onClick={() => update(option.value)} + > + <span class="models-routing__option-label"> + <span>{option.title}</span> + </span> + <span id={`managed-inference-${option.value ?? "automatic"}-description`} class="sr-only"> + {option.body} + </span> + </button> + )} + </For> + </div> + <p class="models-routing__description" aria-live="polite"> + {busy() ? `Saving ${selected().title.toLowerCase()}…` : selected().body} + <Show when={!busy() && refreshing()}> + <span class="models-routing__sync"> Updating model availability…</span> + </Show> + </p> </div> <Show when={wallet() && !wallet()!.signedIn}> - <p class="text-12-regular text-text-weak"> + <p class="settings-inline-note text-12-regular text-text-weak"> Sign in from General to enable managed credits. Own-key and automatic routing remain available. </p> </Show> diff --git a/frontend/workspace/src/components/settings/Models.tsx b/frontend/workspace/src/components/settings/Models.tsx index 439b084e..88075663 100644 --- a/frontend/workspace/src/components/settings/Models.tsx +++ b/frontend/workspace/src/components/settings/Models.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, createSignal, type JSX } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { Icon } from "@synsci/ui/icon" import { Select } from "@synsci/ui/select" import { Switch } from "@synsci/ui/switch" @@ -8,13 +8,18 @@ import { displayProviderForModel, modelSummary } from "@/context/model-catalog" import { CodexConnection } from "./CodexConnection" import { ManagedInference } from "./ManagedInference" import { ProviderKeys } from "./ProviderKeys" +import { ProviderLogo } from "./ProviderLogo" +import { commitPreference } from "./preference-write" import { modelGroup, modelGroupLabel, modelGroupRank } from "../model-groups" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./models.css" type Option = { key: ModelKey label: string latest: boolean provider: string + providerLogo: string group: ReturnType<typeof modelGroup> pinned: boolean reasoning: boolean @@ -23,6 +28,19 @@ type Option = { } type Scope = "all" | "reasoning" | "latest" | "long" +type OptionGroup<T> = { id: string; label: string; models: T[] } + +export function takeModelGroups<T>(groups: OptionGroup<T>[], limit: number): OptionGroup<T>[] { + let remaining = Math.max(0, limit) + const result: OptionGroup<T>[] = [] + for (const group of groups) { + if (remaining <= 0) break + const models = group.models.slice(0, remaining) + if (models.length > 0) result.push({ ...group, models }) + remaining -= models.length + } + return result +} const scopes: Array<{ id: Scope; label: string }> = [ { id: "all", label: "All" }, @@ -37,6 +55,28 @@ export default function Models() { const [query, setQuery] = createSignal("") const [scope, setScope] = createSignal<Scope>("all") const [error, setError] = createSignal<string>() + const [defaultsBusy, setDefaultsBusy] = createSignal(false) + const [optimisticDefaults, setOptimisticDefaults] = createSignal<{ model?: string; small_model?: string }>({}) + + const updateDefault = async (patch: { model?: string; small_model?: string }) => { + if (defaultsBusy()) return + const previous = optimisticDefaults() + setOptimisticDefaults((current) => ({ ...current, ...patch })) + setDefaultsBusy(true) + setError(undefined) + const result = await commitPreference( + async () => { + await sync.updateConfig(patch) + return undefined + }, + () => undefined, + ) + if (!result.ok) { + setOptimisticDefaults(previous) + setError(result.error) + } + setDefaultsBusy(false) + } const options = createMemo<Option[]>(() => { models.pinned.list() @@ -45,6 +85,7 @@ export default function Models() { .map((item) => { const key = { providerID: item.provider.id, modelID: item.id } const pinned = models.pinned.has(key) + const display = displayProviderForModel(item.provider, item.id) return { group: modelGroup(item, pinned), key, @@ -53,7 +94,8 @@ export default function Models() { pinned, reasoning: item.capabilities.reasoning, context: item.limit.context, - provider: displayProviderForModel(item.provider, item.id).name, + provider: display.name, + providerLogo: display.id, value: `${item.provider.id}/${item.id}`, } }) @@ -77,8 +119,10 @@ export default function Models() { }) }) }) - const primary = () => options().find((model) => model.value === sync.data.config.model) - const background = () => options().find((model) => model.value === sync.data.config.small_model) + const primary = () => + options().find((model) => model.value === (optimisticDefaults().model ?? sync.data.config.model)) + const background = () => + options().find((model) => model.value === (optimisticDefaults().small_model ?? sync.data.config.small_model)) const groups = createMemo(() => { const map = new Map<ReturnType<typeof modelGroup>, Option[]>() for (const model of filtered()) map.set(model.group, [...(map.get(model.group) ?? []), model]) @@ -86,6 +130,24 @@ export default function Models() { .map(([id, items]) => ({ id, label: modelGroupLabel(id), models: items })) .sort((a, b) => modelGroupRank(a.id) - modelGroupRank(b.id) || a.label.localeCompare(b.label)) }) + const [renderLimit, setRenderLimit] = createSignal(48) + const visibleGroups = createMemo(() => takeModelGroups(groups(), renderLimit())) + + // Mount the long catalog in bounded chunks. The first viewport is useful + // immediately; the rest fills over subsequent frames instead of forcing one + // click to synchronously create hundreds of rows. + createEffect(() => { + query() + scope() + const total = filtered().length + setRenderLimit(Math.min(48, total)) + if (total <= 48) return + let frame = requestAnimationFrame(function load() { + setRenderLimit((current) => Math.min(total, current + 48)) + if (renderLimit() < total) frame = requestAnimationFrame(load) + }) + onCleanup(() => cancelAnimationFrame(frame)) + }) const [notice, setNotice] = createSignal("New installations start unpinned. Choose up to three quick models.") const togglePin = (model: Option) => { @@ -99,226 +161,228 @@ export default function Models() { } return ( - <div class="flex h-full flex-col overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Models</h2> - <p class="text-13-regular text-text-weak"> - Pick the models you like working with and keep your everyday choices close at hand. - </p> - </div> - </div> - - <div class="settings-page-body"> - <Show when={error()}> - <div - class="rounded-[4px] px-3 py-2 text-12-regular" - style={{ - color: "var(--color-error)", - border: "1px solid var(--color-error-muted)", - }} + <div class="settings-models-panel h-full min-h-0"> + <PanelScroll> + <PanelHeader + title="Models" + description="Choose how OpenScience runs models and keep your everyday options close at hand." + /> + <PanelBody> + <Show when={error()}> + <div role="alert" class="settings-alert text-12-regular" data-tone="critical"> + {error()} + </div> + </Show> + <Section + id="managed-inference" + title="Managed inference" + description="Use OpenScience credits, bring your own key, or use a provider that is already configured." > - {error()} - </div> - </Show> - <section class="flex flex-col gap-3" aria-labelledby="managed-inference"> - <div class="flex flex-col gap-0.5"> - <h3 id="managed-inference" class="text-13-medium text-text-weak"> - Managed inference - </h3> - <p class="text-12-regular text-text-weak"> - Use OpenScience credits, bring your own key, or let us spot one that is already configured. - </p> - </div> - <ManagedInference onError={setError} /> - </section> + <ManagedInference onError={setError} /> + </Section> - <section class="flex flex-col gap-3" aria-labelledby="model-access"> - <div class="flex flex-col gap-0.5"> - <h3 id="model-access" class="text-13-medium text-text-weak"> - ChatGPT / Codex - </h3> - <p class="text-12-regular text-text-weak"> - Already have ChatGPT? Connect it once and use supported OpenAI models here. OAuth tokens stay local. - </p> - </div> - <CodexConnection onError={setError} /> - </section> + <Section + id="model-access" + title="ChatGPT and Codex" + description="Connect once to use supported OpenAI models. OAuth tokens stay on this machine." + > + <CodexConnection onError={setError} /> + </Section> - <section class="flex flex-col gap-3" aria-labelledby="provider-keys"> - <div class="flex flex-col gap-0.5"> - <h3 id="provider-keys" class="text-13-medium text-text-weak"> - Provider keys - </h3> - <p class="text-12-regular text-text-weak"> - Prefer your own account? Add a provider key here. It stays in your owner-only local auth file, and the - provider bills you directly. - </p> - </div> - <ProviderKeys onError={setError} /> - </section> + <Section + id="provider-keys" + title="Provider keys" + description="Use your own provider account. Keys stay in the owner-only local auth file." + > + <ProviderKeys onError={setError} /> + </Section> - <section class="flex flex-col gap-3" aria-labelledby="model-defaults"> - <div class="flex flex-col gap-0.5"> - <h3 id="model-defaults" class="text-13-medium text-text-weak"> - Defaults - </h3> - <p class="text-12-regular text-text-weak"> - Effort and speed stay attached to the selected model in the composer. - </p> - </div> - <div class="overflow-hidden rounded-[4px] border border-border-weak-base bg-surface-base/40"> - <Row title="Research model" detail="Used when a new session starts."> - <Select - options={options()} - current={primary()} - value={(option) => option.value} - label={(option) => option.label} - onSelect={(option) => option && void sync.updateConfig({ model: option.value })} - variant="secondary" - size="small" - triggerVariant="settings" - placeholder="Auto" - /> - </Row> - <Row title="Background model" detail="Used for titles and compact background work."> - <Select - options={options()} - current={background()} - value={(option) => option.value} - label={(option) => option.label} - onSelect={(option) => option && void sync.updateConfig({ small_model: option.value })} - variant="secondary" - size="small" - triggerVariant="settings" - placeholder="Auto" - /> - </Row> - </div> - </section> + <Section + id="model-defaults" + title="Defaults" + description="Choose the starting model for research and lightweight background work." + > + <div class="settings-card settings-defaults-card"> + <Row title="Research model" detail="Used when a new session starts."> + <Select + options={options()} + current={primary()} + value={(option) => option.value} + label={(option) => option.label} + disabled={defaultsBusy()} + onSelect={(option) => option && void updateDefault({ model: option.value })} + variant="secondary" + size="small" + triggerVariant="settings" + placeholder="Auto" + > + {(option) => ( + <Show when={option}> + {(item) => ( + <span class="models-default-option"> + <ProviderLogo id={item().providerLogo} label={item().provider} size="small" /> + <span class="truncate">{item().label}</span> + <span class="models-default-option__provider">{item().provider}</span> + </span> + )} + </Show> + )} + </Select> + </Row> + <Row title="Background model" detail="Used for titles and compact background work."> + <Select + options={options()} + current={background()} + value={(option) => option.value} + label={(option) => option.label} + disabled={defaultsBusy()} + onSelect={(option) => option && void updateDefault({ small_model: option.value })} + variant="secondary" + size="small" + triggerVariant="settings" + placeholder="Auto" + > + {(option) => ( + <Show when={option}> + {(item) => ( + <span class="models-default-option"> + <ProviderLogo id={item().providerLogo} label={item().provider} size="small" /> + <span class="truncate">{item().label}</span> + <span class="models-default-option__provider">{item().provider}</span> + </span> + )} + </Show> + )} + </Select> + </Row> + </div> + </Section> - <section class="flex flex-col gap-3" aria-labelledby="model-visibility"> - <div class="flex flex-col gap-0.5"> - <h3 id="model-visibility" class="text-13-medium text-text-weak"> - Composer models - </h3> - <p class="text-12-regular text-text-weak"> - Pick quick models and control what appears in the composer. Hidden models remain available here. - </p> - </div> - <div class="flex items-center justify-between gap-3"> - <p class="text-11-regular text-text-weak" aria-live="polite"> - {notice()} - </p> - <span class="shrink-0 rounded-full border border-border-weak-base px-2 py-0.5 text-11-medium text-text-weak"> - {models.pinned.list().length}/3 pinned - </span> - </div> - <div class="flex flex-col gap-2"> - <label class="flex h-9 items-center gap-2 rounded-[7px] border border-border-weak-base bg-surface-base px-3 text-text-weak focus-within:border-border-strong-base"> - <Icon name="magnifying-glass" size="small" /> - <input - type="search" - aria-label="Filter models" - value={query()} - onInput={(event) => setQuery(event.currentTarget.value)} - placeholder="Search name, provider, or try is:reasoning" - class="min-w-0 flex-1 bg-transparent text-13-regular text-text-strong outline-none placeholder:text-text-weaker" - /> - </label> - <div class="flex flex-wrap items-center gap-1.5" aria-label="Model filters"> - <For each={scopes}> - {(item) => ( - <button - type="button" - aria-pressed={scope() === item.id} - onClick={() => setScope(item.id)} - class="min-h-7 rounded-[6px] border px-2.5 text-11-medium transition-colors hover:text-text-strong focus-visible:outline focus-visible:outline-1 focus-visible:outline-border-strong" - classList={{ - "border-border-strong-base bg-surface-raised-base text-text-strong": scope() === item.id, - "border-border-weak-base bg-transparent text-text-weak": scope() !== item.id, - }} - > - {item.label} - </button> - )} - </For> + <Section + id="model-visibility" + title="Composer models" + description="Pin quick choices and hide models you do not want in the composer." + > + <div class="flex min-w-0 flex-wrap items-center justify-between gap-3"> + <p class="min-w-0 flex-1 break-words text-11-regular text-text-weak" aria-live="polite"> + {notice()} + </p> + <span class="shrink-0 rounded-full border border-border-weak-base px-2 py-0.5 text-11-medium text-text-weak"> + {models.pinned.list().length}/3 pinned + </span> </div> - </div> - <div class="overflow-hidden rounded-[6px] border border-border-weak-base bg-surface-base/40"> - <For each={groups()}> - {(group) => ( - <section aria-labelledby={`composer-models-${group.id.replace(/[^a-z0-9-]/gi, "-")}`}> - <div class="flex min-h-8 items-center border-b border-border-weak-base bg-surface-raised-base/70 px-4"> - <h4 - id={`composer-models-${group.id.replace(/[^a-z0-9-]/gi, "-")}`} - class="text-11-medium tracking-[0.01em] text-text-weak" + <div class="flex flex-col gap-2"> + <label class="settings-control settings-control--search text-text-weak"> + <Icon name="magnifying-glass" size="small" /> + <input + type="search" + aria-label="Filter models" + value={query()} + onInput={(event) => setQuery(event.currentTarget.value)} + placeholder="Search name, provider, or try is:reasoning" + class="min-w-0 flex-1 bg-transparent text-13-regular text-text-strong outline-none placeholder:text-text-weaker" + /> + </label> + <div class="flex flex-wrap items-center gap-1.5" aria-label="Model filters"> + <For each={scopes}> + {(item) => ( + <button + type="button" + aria-pressed={scope() === item.id} + onClick={() => setScope(item.id)} + class="settings-filter-pill" > - {group.label} - </h4> - </div> - <For each={group.models}> - {(model) => ( - <div class="flex min-h-[52px] items-center justify-between gap-3 border-b border-border-weak-base px-4 py-2.5 last:border-none"> - <div class="flex min-w-0 flex-1 flex-col gap-0.5"> - <span class="flex min-w-0 items-center gap-2"> - <strong class="truncate text-13-medium text-text-strong">{model.label}</strong> - <Show when={model.latest}> - <span class="shrink-0 text-10-medium text-text-weaker">Latest</span> - </Show> - </span> - <span class="truncate text-11-regular text-text-weak"> - {modelSummary({ - reasoning: model.reasoning, - context: model.context, - provider: model.provider, - })} - </span> - </div> - <div class="flex shrink-0 items-center gap-2"> - <button - type="button" - class="flex size-7 items-center justify-center rounded-[5px] text-text-weak hover:bg-surface-raised-base hover:text-text-strong focus-visible:outline focus-visible:outline-1 focus-visible:outline-border-strong" - data-pinned={model.pinned ? "true" : undefined} - aria-pressed={model.pinned} - aria-label={`${model.pinned ? "Unpin" : "Pin"} ${model.label}`} - title={model.pinned ? "Remove from quick models" : "Pin to quick models"} - onClick={() => togglePin(model)} - > - <Icon name={model.pinned ? "pin-filled" : "pin"} size="small" /> - </button> - <Switch - hideLabel - checked={models.visible(model.key)} - onChange={(checked) => models.setVisibility(model.key, checked)} - > - {`${models.visible(model.key) ? "Hide" : "Show"} ${model.label}`} - </Switch> + {item.label} + </button> + )} + </For> + </div> + </div> + <div class="settings-card settings-model-catalog"> + <For each={visibleGroups()}> + {(group) => ( + <section aria-labelledby={`composer-models-${group.id.replace(/[^a-z0-9-]/gi, "-")}`}> + <div class="settings-list-header"> + <h4 + id={`composer-models-${group.id.replace(/[^a-z0-9-]/gi, "-")}`} + class="text-11-medium text-text-weak" + > + {group.label} + </h4> + </div> + <For each={group.models}> + {(model) => ( + <div class="settings-row settings-model-row models-compact-row"> + <div class="models-model-identity"> + <ProviderLogo id={model.providerLogo} label={model.provider} size="small" /> + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="flex min-w-0 items-center gap-2"> + <strong class="truncate text-13-medium text-text-strong">{model.label}</strong> + <Show when={model.latest}> + <span class="shrink-0 text-10-medium text-text-weaker">Latest</span> + </Show> + </span> + <span class="truncate text-11-regular text-text-weak"> + {modelSummary({ + reasoning: model.reasoning, + context: model.context, + provider: model.provider, + })} + </span> + </div> + </div> + <div class="ml-auto flex max-w-full shrink-0 items-center gap-2"> + <button + type="button" + class="settings-icon-action" + data-pinned={model.pinned ? "true" : undefined} + aria-pressed={model.pinned} + aria-label={`${model.pinned ? "Unpin" : "Pin"} ${model.label}`} + title={model.pinned ? "Remove from quick models" : "Pin to quick models"} + onClick={() => togglePin(model)} + > + <Icon name={model.pinned ? "pin-filled" : "pin"} size="small" /> + </button> + <Switch + hideLabel + checked={models.visible(model.key)} + onChange={(checked) => models.setVisibility(model.key, checked)} + > + {`${models.visible(model.key) ? "Hide" : "Show"} ${model.label}`} + </Switch> + </div> </div> - </div> - )} - </For> - </section> - )} - </For> - <Show when={filtered().length === 0}> - <p class="px-4 py-6 text-center text-12-regular text-text-weak">No models match this filter.</p> - </Show> - </div> - </section> - </div> + )} + </For> + </section> + )} + </For> + <Show when={filtered().length === 0}> + <p class="px-4 py-6 text-center text-12-regular text-text-weak">No models match this filter.</p> + </Show> + <Show when={renderLimit() < filtered().length}> + <p class="models-catalog-progress" role="status"> + Loading more models… + </p> + </Show> + </div> + </Section> + </PanelBody> + </PanelScroll> </div> ) } function Row(props: { title: string; detail: string; children: JSX.Element }) { return ( - <div class="flex flex-wrap items-center justify-between gap-4 border-b border-border-weak-base px-4 py-3 last:border-none"> - <div class="flex min-w-0 flex-col gap-0.5"> - <span class="text-13-medium text-text-strong">{props.title}</span> - <span class="text-12-regular text-text-weak">{props.detail}</span> + <div class="settings-row justify-between"> + <div class="flex min-w-0 flex-1 basis-[220px] items-center"> + <div class="flex min-w-0 flex-col gap-0.5"> + <span class="text-13-medium text-text-strong">{props.title}</span> + <span class="text-12-regular text-text-weak">{props.detail}</span> + </div> </div> - <div class="flex-shrink-0">{props.children}</div> + <div class="ml-auto max-w-full flex-shrink-0">{props.children}</div> </div> ) } diff --git a/frontend/workspace/src/components/settings/ModelsComputeUi.test.ts b/frontend/workspace/src/components/settings/ModelsComputeUi.test.ts new file mode 100644 index 00000000..9c3108c2 --- /dev/null +++ b/frontend/workspace/src/components/settings/ModelsComputeUi.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" + +const root = new URL("./", import.meta.url) +const read = (path: string) => Bun.file(new URL(path, root)).text() + +describe("models and compute settings UI contract", () => { + test("presents inference routing as one semantic choice group", async () => { + const source = await read("ManagedInference.tsx") + + expect(source).not.toContain('icon: "bolt"') + expect(source).not.toContain('icon: "providers"') + expect(source).not.toContain('icon: "sparkles"') + expect(source).toContain('class="models-routing__option"') + expect(source).toContain('class="models-routing__option-label"') + expect(source).not.toContain('name="check-small"') + expect(source).toContain("aria-describedby=") + expect(source).toContain("aria-pressed=") + expect(source).toContain("setMode(value)") + expect(source).toContain("setBusy(false)") + expect(source.indexOf("setBusy(false)")).toBeLessThan(source.indexOf(".refreshProviders()")) + }) + + test("uses compact semantic status and action affordances for model access", async () => { + const models = await read("Models.tsx") + const codex = await read("CodexConnection.tsx") + const keys = await read("ProviderKeys.tsx") + + expect(models).toContain('<Row title="Research model"') + expect(models).toContain('<Row title="Background model"') + expect(models).toContain("takeModelGroups(groups(), renderLimit())") + expect(models).toContain("providerLogo: display.id") + expect(models).toContain("<ProviderLogo id={model.providerLogo}") + expect(codex).toContain('class="settings-status" data-tone="ready"') + expect(codex).toContain('class="settings-panel-action settings-panel-action--quiet models-secondary-action"') + expect(keys).toContain("settings-provider-key-form") + expect(keys).toContain('class="models-provider-options"') + expect(keys).toContain('triggerVariant="settings"') + expect(keys).toContain("<ProviderLogo id={entry().id}") + expect(keys).toContain('class="settings-status" data-tone="ready"') + expect(keys).toContain("sdk.client.auth.set") + expect(keys).toContain("sdk.client.auth.remove") + expect(keys).not.toContain("sdk.client.global.dispose") + expect(keys.indexOf("setSaving(false)")).toBeLessThan(keys.indexOf('refreshAfterSave("Key saved")')) + }) + + test("gives compute targets semantic icons and exposes pinned remote dispatch", async () => { + const source = await read("Compute.tsx") + + expect(source).toContain('icon="braces"') + expect(source).toContain('icon="console"') + expect(source).toContain('icon="server"') + expect(source).toContain('<Badge tone="ready">Automatic</Badge>') + expect(source).toContain('<Badge tone="ready">Ready</Badge>') + expect(source).toContain("Choose where agent-managed Python, R, shell, and batch work runs.") + expect(source).not.toContain("Coming soon") + expect(source).toContain("Pin a host key, then dispatch staged jobs through your active SSH agent.") + expect(source).toContain("Ready to dispatch") + expect(source).not.toContain("not execution targets") + expect(source).not.toContain("Remote job dispatch remains unavailable") + expect(source).toContain('class="settings-panel-action settings-panel-action--danger-quiet"') + }) + + test("does not reintroduce hard-coded black or white islands", async () => { + for (const path of [ + "Models.tsx", + "ManagedInference.tsx", + "CodexConnection.tsx", + "ProviderKeys.tsx", + "Compute.tsx", + ]) { + const source = await read(path) + expect(source).not.toMatch(/\bbg-(?:black|white)\b/) + expect(source).not.toMatch(/(?:#000(?:000)?|#fff(?:fff)?|background:\s*(?:black|white))/i) + } + }) + + test("keeps the model panel neutral and compact", async () => { + const models = await read("models.css") + + expect(models).toContain("--models-control-height: 32px") + expect(models).toContain("background: var(--settings-primary)") + expect(models).toContain("background: var(--settings-toggle-active)") + expect(models).toContain("outline-color: var(--border-focus)") + expect(models).toContain("background: var(--settings-surface-muted)") + expect(models).toContain("background: var(--settings-surface)") + expect(models).toContain("box-shadow: none") + expect(models).not.toMatch(/(?:#007aff|#0a84ff|\bblue\b)/i) + expect(models).not.toContain("var(--settings-accent)") + }) +}) diff --git a/frontend/workspace/src/components/settings/Network.test.ts b/frontend/workspace/src/components/settings/Network.test.ts index 95de50e4..18bf70cc 100644 --- a/frontend/workspace/src/components/settings/Network.test.ts +++ b/frontend/workspace/src/components/settings/Network.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test" -import { networkEndpoint } from "./Network" +import { networkEndpoint } from "./network-endpoint" + +const source = Bun.file(new URL("./Network.tsx", import.meta.url)).text() +const styles = Bun.file(new URL("./preference-panels.css", import.meta.url)).text() test("global network settings do not select a filesystem project", () => { const endpoint = new URL(networkEndpoint("http://127.0.0.1:4096/")) @@ -7,3 +10,51 @@ test("global network settings do not select a filesystem project", () => { expect(endpoint.pathname).toBe("/settings/network") expect([...endpoint.searchParams]).toEqual([]) }) + +test("network settings stay responsive while whole-state writes remain ordered", async () => { + const component = await source + + expect(component).toContain("queuedState = next") + expect(component).toContain("while (queuedState)") + expect(component).toContain("commitNetworkState(pending") + expect(component).toContain('aria-busy={saving() ? "true" : undefined}') + expect(component).not.toContain("disabled={saving()}") +}) + +test("network rows use one disclosure icon instead of repeated decorative icons", async () => { + const component = await source + + expect(component).toContain('class="settings-network-disclosure"') + expect(component).toContain("settings-network-domain-row group") + expect(component).not.toContain('name="shield"') + expect(component).not.toContain('name="server"') + expect(component).not.toContain('name="link"') + expect(component).not.toContain('name="plus"') +}) + +test("network loading keeps the section structure stable and saving stays non-blocking", async () => { + const component = await source + + expect(component).toContain('class="settings-panel-loading__rows settings-network-loading-rows"') + expect(component).toContain('aria-label="Loading service groups"') + expect(component).toContain('aria-label="Loading allowed domains"') + expect(component).toContain('class="settings-network-save-state"') + expect(component).toContain('{saving() ? "Saving…" : ""}') + expect(component).not.toContain("Loading network settings…") +}) + +test("network rows stay compact without a nested disclosure surface", async () => { + const component = await source + const css = await styles + const icon = component.indexOf('class="settings-network-disclosure__icon"') + const copy = component.indexOf('<span class="settings-row-copy">', icon) + + expect(icon).toBeGreaterThan(-1) + expect(copy).toBeGreaterThan(icon) + expect(css).toContain(".settings-preferences-panel--network .settings-network-group-row") + expect(css).toContain("min-height: 56px") + expect(css).toContain(".settings-preferences-panel--network .settings-network-disclosure") + expect(css).toContain("align-self: stretch") + expect(css).toContain("border-radius: 0") + expect(css).not.toContain("margin-left: calc(var(--settings-space-2) * -1)") +}) diff --git a/frontend/workspace/src/components/settings/Network.tsx b/frontend/workspace/src/components/settings/Network.tsx index 4f026d2b..e9825d31 100644 --- a/frontend/workspace/src/components/settings/Network.tsx +++ b/frontend/workspace/src/components/settings/Network.tsx @@ -1,8 +1,17 @@ import { For, Show, createMemo, createSignal, onMount } from "solid-js" import { Icon } from "@synsci/ui/icon" import { Switch } from "@synsci/ui/switch" +import { useDialog } from "@synsci/ui/context/dialog" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" +import { networkEndpoint } from "./network-endpoint" +import { canonicalNetworkDomain } from "./network-domain" +import { commitNetworkState, type NetworkSettingsState } from "./network-write" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./preference-panels.css" + +export { networkEndpoint } from "./network-endpoint" // Outbound domain allow-list. Wired to a real backend store: // GET/PUT /settings/network (backend/cli/src/settings/network.ts). The catalog @@ -11,24 +20,26 @@ import { usePlatform } from "@/context/platform" // allow-list is readable by the backend via Network.allowlist(). type Group = { id: string; label: string; description: string; domains: string[] } -type State = { allowlistEnabled: boolean; enabled: string[]; custom: string[] } +type State = NetworkSettingsState -export function networkEndpoint(baseUrl: string) { - return `${baseUrl.replace(/\/+$/, "")}/settings/network` -} +const emptyState: State = { allowlistEnabled: false, enabled: [], custom: [] } export default function Network() { const sdk = useGlobalSDK() const platform = usePlatform() + const dialog = useDialog() const doFetch = platform.fetch ?? fetch const [catalog, setCatalog] = createSignal<Group[]>([]) - const [state, setState] = createSignal<State>({ allowlistEnabled: false, enabled: [], custom: [] }) + const [state, setState] = createSignal<State>(emptyState) const [loading, setLoading] = createSignal(true) const [saving, setSaving] = createSignal(false) const [error, setError] = createSignal<string>() const [expanded, setExpanded] = createSignal<Record<string, boolean>>({}) const [customDomain, setCustomDomain] = createSignal("") + let confirmedState = emptyState + let queuedState: State | undefined + let writeLoop: Promise<void> | undefined const endpoint = () => networkEndpoint(sdk.url) @@ -40,6 +51,7 @@ export default function Network() { if (!res.ok) throw new Error(await res.text()) const data = (await res.json()) as { catalog: Group[]; state: State } setCatalog(data.catalog) + confirmedState = data.state setState(data.state) } catch (e) { setError(e instanceof Error ? e.message : String(e)) @@ -49,23 +61,51 @@ export default function Network() { } async function persist(next: State) { - const previous = state() + // Keep controls responsive while preserving the endpoint's whole-state + // replacement contract. Rapid edits collapse into the latest queued state + // and writes still reach the backend strictly in order. setState(next) - setSaving(true) + queuedState = next setError(undefined) + if (writeLoop) return writeLoop + + setSaving(true) + const loop = (async () => { + while (queuedState) { + const pending = queuedState + queuedState = undefined + const result = await commitNetworkState(pending, { + isSaving: () => false, + state: () => confirmedState, + setState: (value) => { + confirmedState = value + }, + setSaving: () => {}, + setError, + write: async (value) => { + const res = await doFetch(endpoint(), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }) + if (!res.ok) throw new Error(await res.text()) + const data = (await res.json()) as { state: State } + return data.state + }, + }) + if (!result.ok) { + queuedState = undefined + setState(confirmedState) + break + } + if (!queuedState) setState(confirmedState) + } + })() + writeLoop = loop try { - const res = await doFetch(endpoint(), { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(next), - }) - if (!res.ok) throw new Error(await res.text()) - const data = (await res.json()) as { state: State } - setState(data.state) - } catch (e) { - setState(previous) - setError(e instanceof Error ? e.message : String(e)) + await loop } finally { + if (writeLoop === loop) writeLoop = undefined setSaving(false) } } @@ -84,13 +124,14 @@ export default function Network() { } function addCustom() { - const raw = customDomain() - .trim() - .toLowerCase() - .replace(/^https?:\/\//, "") - .replace(/\/.*$/, "") - if (!raw) return + const result = canonicalNetworkDomain(customDomain()) + if (!result.ok) { + setError(result.error) + return + } + const raw = result.domain setCustomDomain("") + setError(undefined) if (state().custom.includes(raw)) return void persist({ ...state(), custom: [...state().custom, raw] }) } @@ -99,9 +140,15 @@ export default function Network() { void persist({ ...state(), custom: state().custom.filter((d) => d !== domain) }) } - function clearCustom() { + async function clearCustom() { if (state().custom.length === 0) return - if (!window.confirm("Remove all custom allowed domains?")) return + const confirmed = await confirmDialog(dialog, { + title: "Clear allowed domains?", + message: "This removes all custom domains. Curated domain groups are not changed.", + confirmLabel: "Clear domains", + danger: true, + }) + if (!confirmed) return void persist({ ...state(), custom: [] }) } @@ -114,145 +161,209 @@ export default function Network() { onMount(() => void load()) return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Network</h2> - <p class="text-13-regular text-text-weak"> - Control which domains the agent may reach. Enable curated science-connector groups or add your own domains. - </p> - </div> - </div> - - <div class="settings-page-body"> - <Show when={error()}> - <div class="rounded-xs border border-border-weak-base bg-surface-base/40 px-3 py-2 text-12-regular text-text-danger"> - {error()} - </div> - </Show> - - {/* Master allow-list toggle */} - <div class="flex items-center justify-between gap-3 rounded-[4px] border border-border-weak-base bg-surface-base/40 px-4 py-3"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-13-medium text-text-strong">Enforce allow-list</span> - <span class="text-12-regular text-text-weak"> - {state().allowlistEnabled - ? `Web fetches and science connectors may only reach the ${effectiveCount()} allowed domains below. Shell and kernel network access is governed by the sandbox, not this list.` - : "Advisory only — the agent may reach any domain."} + <PanelScroll> + <div + class="settings-preferences-panel settings-preferences-panel--network" + aria-busy={saving() ? "true" : undefined} + > + <PanelHeader + title="Network" + description="Choose which online services research tools can reach." + toolbar={ + <span class="settings-network-save-state" role="status" aria-live="polite"> + {saving() ? "Saving…" : ""} </span> - </div> - <Switch hideLabel checked={state().allowlistEnabled} onChange={toggleAllowlist}> - Enforce allow-list - </Switch> - </div> + } + /> + <PanelBody> + <Show when={error()}> + <div class="settings-alert" data-tone="critical" role="alert"> + <span>{error()}</span> + <button + type="button" + class="settings-inline-action" + disabled={loading() || saving()} + onClick={() => void load()} + > + Retry + </button> + </div> + </Show> - <Show when={!loading()} fallback={<div class="text-13-regular text-text-weak py-6 text-center">Loading…</div>}> - {/* Domain groups */} - <div class="flex flex-col gap-2"> - <span class="atlas-section-label px-1">Domain sets</span> - <For each={catalog()}> - {(group) => { - const on = () => state().enabled.includes(group.id) - const open = () => !!expanded()[group.id] - return ( - <div class="rounded-[4px] border border-border-weak-base bg-surface-base/40 overflow-hidden"> - <div class="flex items-center gap-2 px-3 py-3"> - <button - type="button" - class="flex items-center justify-center size-6 rounded-xs text-icon-weak-base hover:bg-surface-raised-base/60 transition-colors flex-shrink-0" - onClick={() => toggleExpanded(group.id)} - aria-label={`${open() ? "Collapse" : "Expand"} ${group.label}`} - > - <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" /> - </button> - <div class="flex flex-col gap-0.5 min-w-0 flex-1"> - <span class="text-13-medium text-text-strong truncate">{group.label}</span> - <span class="text-12-regular text-text-weak truncate"> - {group.description} · {group.domains.length} domains - </span> - </div> - <Switch hideLabel checked={on()} onChange={(v) => toggleGroup(group.id, v)}> - {`Allow ${group.label}`} - </Switch> - </div> - <Show when={open()}> - <div class="flex flex-wrap gap-1.5 px-4 pb-3 pt-0"> - <For each={group.domains}> - {(domain) => ( - <span class="inline-flex items-center h-6 px-2 rounded-xs bg-surface-raised-base/50 text-11-regular text-text-weak font-mono"> - {domain} + <Section title="Access policy" description="Web fetches and science connectors follow this policy."> + <div class="settings-card settings-preferences-card"> + <div class="settings-row settings-preference-row settings-network-policy-row"> + <div class="settings-row-copy"> + <strong>Restrict network access</strong> + <span> + {state().allowlistEnabled + ? `Only ${effectiveCount()} approved domains may be reached.` + : "Connections are not restricted by the domain list."} + </span> + </div> + <Switch hideLabel checked={state().allowlistEnabled} disabled={loading()} onChange={toggleAllowlist}> + Restrict network access + </Switch> + </div> + </div> + </Section> + + <Section title="Service groups" description="Use maintained domain sets for common research services."> + <Show + when={!loading()} + fallback={ + <div + class="settings-panel-loading__rows settings-network-loading-rows" + role="status" + aria-label="Loading service groups" + > + <span /> + <span /> + <span /> + <span /> + <span /> + <span /> + </div> + } + > + <div class="settings-card settings-preferences-card settings-network-groups"> + <For + each={catalog()} + fallback={ + <span class="settings-empty-copy settings-network-empty-copy">No service groups available.</span> + } + > + {(group) => { + const on = () => state().enabled.includes(group.id) + const open = () => !!expanded()[group.id] + const detailsId = () => `network-group-${group.id}` + return ( + <div class="settings-list-item"> + <div class="settings-row settings-preference-row settings-network-group-row"> + <button + type="button" + class="settings-network-disclosure" + onClick={() => toggleExpanded(group.id)} + aria-label={`${open() ? "Collapse" : "Expand"} ${group.label}`} + aria-expanded={open()} + aria-controls={detailsId()} + > + <Icon + name={open() ? "chevron-down" : "chevron-right"} + size="small" + class="settings-network-disclosure__icon" + /> + <span class="settings-row-copy"> + <strong class="truncate">{group.label}</strong> + <span class="settings-network-group-description"> + {group.description} · <span class="tabular-nums">{group.domains.length}</span> domains + </span> </span> - )} - </For> + </button> + <Switch hideLabel checked={on()} onChange={(v) => toggleGroup(group.id, v)}> + {`Allow ${group.label}`} + </Switch> + </div> + <Show when={open()}> + <div id={detailsId()} class="settings-preference-disclosure"> + <ul class="settings-preference-domain-list" aria-label={`${group.label} domains`}> + <For each={group.domains}> + {(domain) => ( + <li> + <code>{domain}</code> + </li> + )} + </For> + </ul> + </div> + </Show> </div> - </Show> - </div> - ) - }} - </For> - </div> + ) + }} + </For> + </div> + </Show> + </Section> - {/* Custom allowed domains */} - <div class="flex flex-col gap-2"> - <div class="flex items-center justify-between px-1"> - <span class="atlas-section-label">Allowed domains</span> + <Section + title="Allowed domains" + description="Add domains that are specific to your work." + action={ <Show when={state().custom.length > 0}> <button type="button" - class="text-11-medium text-text-danger hover:opacity-80 transition-opacity" - disabled={saving()} + class="settings-inline-action text-text-danger" + data-quiet="true" onClick={clearCustom} aria-label="Clear allowed domains" > Clear </button> </Show> - </div> - <div class="rounded-[4px] border border-border-weak-base bg-surface-base/40 overflow-hidden"> - <For - each={state().custom} - fallback={<span class="block px-4 py-3 text-12-regular text-text-weak/70">No custom domains.</span>} - > - {(domain) => ( - <div class="group flex items-center gap-2 px-4 py-2.5 border-b border-border-weak-base/60 last:border-b-0"> - <span class="flex-1 text-13-regular text-text-base font-mono truncate">{domain}</span> - <button - type="button" - class="flex items-center justify-center size-6 rounded-xs text-icon-weak-base hover:text-text-danger opacity-0 group-hover:opacity-100 transition-opacity" - disabled={saving()} - onClick={() => removeCustom(domain)} - aria-label={`Remove ${domain}`} - > - <Icon name="close-small" size="small" /> - </button> - </div> - )} - </For> - <div class="flex items-center gap-2 px-3 py-2.5 border-t border-border-weak-base"> - <input - type="text" - aria-label="Add allowed domain" - placeholder="Add a domain, e.g. example.org" - value={customDomain()} - disabled={saving()} - class="flex-1 h-9 px-3 rounded-xs border border-border-weak-base bg-surface-raised-base/40 text-13-regular text-text-strong placeholder:text-text-weak/60 outline-none focus:border-border-base font-mono" - onInput={(e) => setCustomDomain(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && addCustom()} - /> - <button - type="button" - class="flex items-center gap-1.5 h-9 px-4 rounded-xs text-13-medium bg-surface-raised-base-active text-text-strong hover:opacity-90 transition-opacity disabled:opacity-50" - disabled={saving() || !customDomain().trim()} - onClick={addCustom} + } + > + <Show + when={!loading()} + fallback={ + <div + class="settings-panel-loading__rows settings-network-loading-rows settings-network-loading-rows--domains" + role="status" + aria-label="Loading allowed domains" > - <Icon name="plus" size="small" /> - Add - </button> + <span /> + <span /> + </div> + } + > + <div class="settings-card settings-preferences-card settings-network-domains"> + <For + each={state().custom} + fallback={ + <span class="settings-empty-copy settings-network-empty-copy">No custom domains added.</span> + } + > + {(domain) => ( + <div class="settings-row settings-preference-row settings-network-domain-row group"> + <code class="settings-network-domain-value max-w-full break-all whitespace-normal min-w-0 text-13-regular text-text-base"> + {domain} + </code> + <button + type="button" + class="settings-icon-action text-icon-weak-base hover:text-text-danger" + onClick={() => removeCustom(domain)} + aria-label={`Remove ${domain}`} + > + <Icon name="close-small" size="small" /> + </button> + </div> + )} + </For> + <div class="settings-row settings-preference-row settings-network-add-row"> + <input + type="text" + aria-label="Add allowed domain" + placeholder="Add a domain, e.g. example.org" + value={customDomain()} + disabled={loading()} + class="settings-field min-w-0 flex-1 basis-[220px] font-mono" + onInput={(e) => setCustomDomain(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && addCustom()} + /> + <button + type="button" + class="settings-preference-action shrink-0" + data-variant="primary" + disabled={loading() || !customDomain().trim()} + onClick={addCustom} + > + Add domain + </button> + </div> </div> - </div> - </div> - </Show> + </Show> + </Section> + </PanelBody> </div> - </div> + </PanelScroll> ) } diff --git a/frontend/workspace/src/components/settings/Permissions.tsx b/frontend/workspace/src/components/settings/Permissions.tsx index 6d0ffcf2..32a4e0a7 100644 --- a/frontend/workspace/src/components/settings/Permissions.tsx +++ b/frontend/workspace/src/components/settings/Permissions.tsx @@ -10,11 +10,16 @@ import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js" import { useParams } from "@solidjs/router" import { Button } from "@synsci/ui/button" +import { Icon } from "@synsci/ui/icon" +import { useDialog } from "@synsci/ui/context/dialog" import { showToast } from "@synsci/ui/toast" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { resolveProjectRoute } from "@/utils/project-route" import { PermissionToolDefaults } from "../settings-permissions" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./preference-panels.css" interface StandingApproval { id: string @@ -28,7 +33,9 @@ const Permissions: Component = () => { const params = useParams() const sdk = useGlobalSDK() const globalSync = useGlobalSync() + const dialog = useDialog() const [busy, setBusy] = createSignal(false) + const [showAllDefaults, setShowAllDefaults] = createSignal(false) const route = createMemo(() => resolveProjectRoute(params.dir, globalSync.data.project)) @@ -40,6 +47,19 @@ const Permissions: Component = () => { }, ) + const [trust, trustControls] = createResource( + () => { + const value = route() + if (!value) return + return { projectID: value.projectID, directory: value.directory } + }, + async (input) => { + const response = await sdk.client.project.trust.get(input) + if (!response.data) throw new Error("Project trust status was empty.") + return response.data + }, + ) + const revoke = async (approval: StandingApproval) => { const directory = route()?.directory if (!directory) return @@ -49,75 +69,220 @@ const Permissions: Component = () => { await refetch() } catch (err) { showToast({ title: "Failed to revoke approval", description: err instanceof Error ? err.message : String(err) }) + } finally { + setBusy(false) } - setBusy(false) } const when = (created: number) => new Date(created).toLocaleDateString() - return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Permissions</h2> - <p class="text-13-regular text-text-weak"> - Standing approvals you have granted, and how the agent may use tools. - </p> - </div> - </div> - - <div class="settings-page-body"> - {/* ── Standing approvals ── */} - <Show when={route()}> - <div class="flex flex-col gap-3"> - <div class="flex flex-col gap-0.5"> - <h3 class="text-13-medium text-text-weak tracking-wide">Standing approvals</h3> - <p class="text-12-regular text-text-weak"> - Granted from permission cards with “This project” or “Always” scope. Revoking one makes that action - prompt again. - </p> - </div> + const updateTrust = async (trusted: boolean) => { + const value = route() + const status = trust() + if (!value || !status || busy()) return + const confirmed = await confirmDialog(dialog, { + title: trusted ? "Trust this project?" : "Revoke project trust?", + message: trusted + ? `Allow project code under ${status.root} to run using the current execution policy. If sandboxing is off or unavailable and fallback permits it, code may run with your user authority. Review Sandbox settings first.` + : "New terminals, kernels, package installs, and compute jobs will stay blocked until you trust this project again. Existing processes are stopped when trust is revoked.", + confirmLabel: trusted ? "Trust project" : "Revoke trust", + danger: !trusted, + }) + if (!confirmed) return + setBusy(true) + try { + await sdk.client.project.trust.update({ + projectID: value.projectID, + directory: value.directory, + body: trusted ? { trusted: true, root: status.root } : { trusted: false }, + }) + await trustControls.refetch() + showToast({ + variant: "success", + title: trusted ? "Project trusted" : "Project trust revoked", + }) + } catch (error) { + showToast({ + title: trusted ? "Could not trust project" : "Could not revoke project trust", + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setBusy(false) + } + } - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> - <Show - when={(standing() ?? []).length > 0} - fallback={ - <div class="px-4 py-5 text-12-regular text-text-weak"> - No standing approvals yet. Conversation-scoped approvals end with their session and are never listed - here. - </div> - } - > - <For each={standing()}> - {(approval) => ( - <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3 border-b border-border-weak-base last:border-none"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-13-medium text-text-strong break-all"> - {approval.permission} - <Show when={approval.pattern !== "*"}> - <span class="text-text-weak font-normal"> · {approval.pattern}</span> - </Show> + return ( + <PanelScroll> + <div class="settings-preferences-panel settings-preferences-panel--permissions"> + <PanelHeader title="Permissions" description="Control project trust, approvals, and tool behavior." /> + <PanelBody> + <Show when={route()}> + <Section + title="Project execution" + description="Project code runs only after you trust its current location." + > + <div class="settings-card settings-preferences-card"> + <Show + when={!trust.loading} + fallback={<div class="settings-panel-loading-copy">Checking project trust…</div>} + > + <Show + when={!trust.error} + fallback={ + <div class="settings-row settings-preference-row" role="alert"> + <span class="settings-preference-icon" data-tone="warning" aria-hidden="true"> + <Icon name="alert-circle" size="small" /> </span> - <span class="text-11-regular text-text-weak"> - {approval.scope === "global" ? "Everywhere" : "This project"} · granted{" "} - {when(approval.created)} + <span class="min-w-0 flex-1 text-12-regular text-text-danger"> + Project trust could not be loaded. {String(trust.error)} </span> + <Button size="small" variant="ghost" onClick={() => void trustControls.refetch()}> + Retry + </Button> </div> - <Button size="small" variant="ghost" disabled={busy()} onClick={() => void revoke(approval)}> - Revoke + } + > + <div class="settings-row settings-preference-row justify-between"> + <span + class="settings-preference-icon" + data-tone={trust()?.canExecuteProjectCode ? "success" : "warning"} + aria-hidden="true" + > + <Icon name={trust()?.canExecuteProjectCode ? "shield" : "shield-alert"} size="small" /> + </span> + <div class="settings-row-copy"> + <strong>{trust()?.canExecuteProjectCode ? "Trusted project" : "Execution blocked"}</strong> + <span class="text-11-regular text-text-weak break-all">{trust()?.root}</span> + </div> + <span + class="settings-preference-status" + data-tone={trust()?.canExecuteProjectCode ? "success" : "warning"} + > + {trust()?.canExecuteProjectCode ? "Trusted" : "Blocked"} + </span> + <Button + size="small" + variant={trust()?.canExecuteProjectCode ? "ghost" : "secondary"} + disabled={busy() || !trust()} + onClick={() => void updateTrust(!trust()?.canExecuteProjectCode)} + > + {trust()?.canExecuteProjectCode ? "Revoke trust" : "Trust project"} </Button> </div> - )} - </For> - </Show> + </Show> + </Show> + </div> + </Section> + </Show> + + {/* ── Standing approvals ── */} + <Show when={route()}> + <Section + title="Standing approvals" + description="Approvals granted for this project or every project. Revoke one to ask again next time." + > + <div class="settings-card settings-preferences-card"> + <Show + when={!standing.loading} + fallback={<div class="settings-panel-loading-copy">Loading standing approvals…</div>} + > + <Show + when={!standing.error} + fallback={ + <div class="settings-row settings-preference-row" role="alert"> + <span class="settings-preference-icon" data-tone="warning" aria-hidden="true"> + <Icon name="alert-circle" size="small" /> + </span> + <span class="min-w-0 flex-1 text-12-regular text-text-danger"> + Standing approvals could not be loaded. {String(standing.error)} + </span> + <Button size="small" variant="ghost" disabled={standing.loading} onClick={() => void refetch()}> + Retry + </Button> + </div> + } + > + <Show + when={(standing() ?? []).length > 0} + fallback={ + <div class="settings-row settings-preference-row"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name="checklist" size="small" /> + </span> + <span class="min-w-0 flex-1 text-12-regular text-text-weak"> + No standing approvals yet. Conversation-scoped approvals end with their session and are + never listed here. + </span> + </div> + } + > + <For each={standing()}> + {(approval) => ( + <div class="settings-row settings-preference-row justify-between"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name="checklist" size="small" /> + </span> + <div class="settings-row-copy"> + <strong class="break-all"> + {approval.permission} + <Show when={approval.pattern !== "*"}> + <span class="text-text-weak font-normal"> · {approval.pattern}</span> + </Show> + </strong> + <span class="text-11-regular text-text-weak"> + {approval.scope === "global" ? "Everywhere" : "This project"} · granted{" "} + {when(approval.created)} + </span> + </div> + <span class="ml-auto shrink-0"> + <Button + size="small" + variant="ghost" + disabled={busy()} + onClick={() => void revoke(approval)} + > + Revoke + </Button> + </span> + </div> + )} + </For> + </Show> + </Show> + </Show> + </div> + </Section> + </Show> + + {/* Tool defaults are a long list, so keep the most common controls visible first. */} + <div + id="permission-tool-defaults" + class="settings-permission-defaults settings-disclosure-group" + data-expanded={showAllDefaults() ? "true" : "false"} + > + <PermissionToolDefaults /> + <div class="settings-disclosure-footer"> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + aria-expanded={showAllDefaults()} + aria-controls="permission-tool-defaults" + onClick={() => setShowAllDefaults((value) => !value)} + > + <Icon + name="chevron-down" + size="small" + classList={{ "rotate-180": showAllDefaults() }} + aria-hidden="true" + /> + {showAllDefaults() ? "Show fewer tool defaults" : "Show all tool defaults"} + </button> </div> </div> - </Show> - - {/* ── Tool defaults (config-backed) ── */} - <PermissionToolDefaults /> + </PanelBody> </div> - </div> + </PanelScroll> ) } diff --git a/frontend/workspace/src/components/settings/ProviderKeys.tsx b/frontend/workspace/src/components/settings/ProviderKeys.tsx index ab6c6a08..335a40d8 100644 --- a/frontend/workspace/src/components/settings/ProviderKeys.tsx +++ b/frontend/workspace/src/components/settings/ProviderKeys.tsx @@ -1,13 +1,14 @@ import { For, Show, createMemo, createSignal } from "solid-js" import { Button } from "@synsci/ui/button" +import { useDialog } from "@synsci/ui/context/dialog" import { Select } from "@synsci/ui/select" import type { Provider } from "@synsci/sdk/v2/client" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useProviders } from "@/hooks/use-providers" import { isUserProviderConnection } from "@/context/model-catalog" import { MODEL_PROVIDERS, MODEL_PROVIDER_LABELS, modelProvider } from "./model-providers" -import { credentialChange } from "./credential-change" import { ProviderLogo } from "./ProviderLogo" /** @@ -58,9 +59,11 @@ export function ProviderKeys(props: { onError?: (error: string | undefined) => v const sdk = useGlobalSDK() const sync = useGlobalSync() const providers = useProviders() + const dialog = useDialog() const [provider, setProvider] = createSignal<string>(MODEL_PROVIDERS[0].id) const [key, setKey] = createSignal("") const [saving, setSaving] = createSignal(false) + const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)) const connected = createMemo(() => providers .connected() @@ -74,81 +77,99 @@ export function ProviderKeys(props: { onError?: (error: string | undefined) => v ), ) const source = (item: { id: string }) => SOURCES[(item as { source?: Provider["source"] }).source ?? "api"] + const refreshAfterSave = (done: string) => { + void sync + .refreshProviders() + .catch((error) => + props.onError?.( + `${done}, but the model list could not be reloaded (${reason(error)}). It will catch up on the next refresh.`, + ), + ) + } const save = async () => { const value = key().trim() if (!value || saving()) return setSaving(true) props.onError?.(undefined) - // Don't wait on the disposed event to come back round the event stream — - // if it is missed the key is saved but never appears, which reads as a - // failed save. A refresh that fails outright is not a failed save either: - // the input has already been cleared by then, so reporting it as one puts - // an error banner over an empty field for a key that is on disk. - const outcome = await credentialChange({ - write: async () => { - await sdk.client.auth.set({ providerID: provider(), auth: { type: "api", key: value } }) - setKey("") - await sdk.client.global.dispose() - }, - refresh: () => sync.refreshProviders(), - done: "Key saved", - }) - setSaving(false) - props.onError?.(outcome.notice) + try { + await sdk.client.auth.set({ providerID: provider(), auth: { type: "api", key: value } }) + setKey("") + // The credential is on disk now. Re-enable the form before rebuilding + // the large provider catalog; auth.set already invalidates the server's + // provider map, so disposing every workspace here only added latency. + setSaving(false) + refreshAfterSave("Key saved") + } catch (error) { + props.onError?.(reason(error)) + } finally { + setSaving(false) + } } const remove = async (providerID: string) => { - if (!window.confirm(`Remove the ${MODEL_PROVIDER_LABELS[providerID] ?? providerID} key from this machine?`)) return - props.onError?.(undefined) - const outcome = await credentialChange({ - write: async () => { - await sdk.client.auth.remove({ providerID }) - await sdk.client.global.dispose() - }, - refresh: () => sync.refreshProviders(), - done: "Key removed", + if (saving()) return + const label = MODEL_PROVIDER_LABELS[providerID] ?? providerID + const confirmed = await confirmDialog(dialog, { + title: `Remove ${label} key?`, + message: + "This removes the saved API key from this machine. Provider access through other sources is not changed.", + confirmLabel: "Remove key", + danger: true, }) - props.onError?.(outcome.notice) + if (!confirmed) return + setSaving(true) + props.onError?.(undefined) + try { + await sdk.client.auth.remove({ providerID }) + setSaving(false) + refreshAfterSave("Key removed") + } catch (error) { + props.onError?.(reason(error)) + } finally { + setSaving(false) + } } return ( - <div class="flex flex-col gap-3"> + <div class="models-provider-keys"> <form - class="grid grid-cols-1 gap-2 rounded-[4px] border border-border-weak-base bg-surface-base/40 p-4 sm:grid-cols-[180px_1fr_auto] sm:items-end" + class="settings-card settings-provider-key-form models-provider-key-form" onSubmit={(event) => { event.preventDefault() void save() }} > - <label class="flex flex-col gap-1.5"> + <label class="models-key-field"> <span class="text-12-medium text-text-weak">Provider</span> - <div class="relative"> - <span class="pointer-events-none absolute left-1 top-1/2 z-[1] -translate-y-1/2"> + <div class="models-provider-select"> + <span class="models-provider-select__mark"> <ProviderLogo id={provider()} label={modelProvider(provider()).label} size="small" /> </span> <Select aria-label="Model provider" + class="models-provider-options" options={[...MODEL_PROVIDERS]} current={modelProvider(provider())} value={(item) => item.id} label={(item) => item.label} + disabled={saving()} onSelect={(item) => item && setProvider(item.id)} variant="secondary" size="small" + triggerVariant="settings" triggerStyle={{ width: "100%", - height: "32px", "justify-content": "space-between", - "padding-left": "36px", + "padding-left": "34px", }} > {(item) => ( <Show when={item}> {(entry) => ( - <span class="flex items-center gap-2.5"> + <span class="flex min-w-0 items-center gap-2.5"> <ProviderLogo id={entry().id} label={entry().label} size="small" /> - <span>{entry().label}</span> + <span class="min-w-0 truncate">{entry().label}</span> </span> )} </Show> @@ -156,51 +177,71 @@ export function ProviderKeys(props: { onError?: (error: string | undefined) => v </Select> </div> </label> - <label class="flex min-w-0 flex-col gap-1.5"> + <label class="models-key-field"> <span class="text-12-medium text-text-weak">API key</span> <input type="password" autocomplete="off" spellcheck={false} + disabled={saving()} value={key()} onInput={(event) => setKey(event.currentTarget.value)} placeholder={modelProvider(provider()).placeholder} - class="h-8 rounded-[4px] border border-border-weak-base bg-surface-base px-2.5 font-mono text-13-regular text-text-strong outline-none placeholder:text-text-weak focus:border-border-strong-base" + class="settings-field settings-provider-key models-key-input" /> </label> - <Button type="submit" size="small" variant="primary" disabled={saving() || !key().trim()}> + <Button + class="settings-panel-action models-primary-action models-save-key" + type="submit" + size="small" + variant="primary" + disabled={saving() || !key().trim()} + > {saving() ? "Saving…" : "Save key"} </Button> </form> <Show when={connected().length > 0}> - <div class="overflow-hidden rounded-[4px] border border-border-weak-base bg-surface-base/40"> + <div class="settings-card models-connected-providers"> <For each={connected()}> {(item) => ( - <div class="flex items-center justify-between gap-3 border-b border-border-weak-base px-4 py-3 last:border-none"> - <div class="flex min-w-0 items-center gap-2.5"> + <div class="settings-row models-compact-row models-provider-row"> + <div class="models-provider-identity min-w-0 flex-1 basis-[220px]"> <ProviderLogo id={item.id} label={MODEL_PROVIDER_LABELS[item.id] ?? item.id} connected /> - <span class="truncate text-13-medium text-text-strong"> - {MODEL_PROVIDER_LABELS[item.id] ?? item.id} - </span> - <span - class="flex-shrink-0 rounded-[4px] border border-border-weak-base px-1.5 py-0.5 text-11-regular text-text-weak" - title={source(item).title} - > - {source(item).label} - </span> + <div class="models-provider-copy"> + <span class="truncate text-13-medium text-text-strong"> + {MODEL_PROVIDER_LABELS[item.id] ?? item.id} + </span> + <div class="models-provider-meta"> + <div class="settings-status" data-tone="ready"> + <span class="settings-status__dot" aria-hidden="true" /> + Available + </div> + <span class="models-provider-source" title={source(item).title}> + {source(item).label} + </span> + </div> + </div> </div> <Show when={source(item).removable} fallback={ - <span class="text-11-regular text-text-weak" title={source(item).title}> + <span class="models-provider-note text-11-regular text-text-weak" title={source(item).title}> {source(item).note ?? "managed externally"} </span> } > - <Button size="small" variant="secondary" onClick={() => void remove(item.id)}> - Remove - </Button> + <span class="models-row-action"> + <Button + class="settings-panel-action settings-panel-action--quiet models-secondary-action" + size="small" + variant="secondary" + disabled={saving()} + onClick={() => void remove(item.id)} + > + Remove + </Button> + </span> </Show> </div> )} diff --git a/frontend/workspace/src/components/settings/ProviderLogo.test.ts b/frontend/workspace/src/components/settings/ProviderLogo.test.ts index f25d1873..9eb08fa3 100644 --- a/frontend/workspace/src/components/settings/ProviderLogo.test.ts +++ b/frontend/workspace/src/components/settings/ProviderLogo.test.ts @@ -23,6 +23,12 @@ describe("provider logos", () => { for (const provider of MODEL_PROVIDERS) expect(providerLogoSource(provider.id).kind).not.toBe("fallback") }) + test("normalizes catalog aliases to real provider marks", () => { + expect(providerLogoSource("DeepSeek")).toEqual({ kind: "provider", id: "deepseek" }) + for (const id of ["DeepSeek", "deep-seek", "deepseek-ai", "moonshot", "kimi", "z-ai", "zhipuai"]) + expect(providerLogoSource(id).kind).not.toBe("fallback") + }) + test("covers every built-in compute and integration credential", () => { for (const id of CREDENTIALS) expect(providerLogoSource(id).kind).not.toBe("fallback") }) diff --git a/frontend/workspace/src/components/settings/ProviderLogo.tsx b/frontend/workspace/src/components/settings/ProviderLogo.tsx index b116d298..47946cbd 100644 --- a/frontend/workspace/src/components/settings/ProviderLogo.tsx +++ b/frontend/workspace/src/components/settings/ProviderLogo.tsx @@ -61,6 +61,7 @@ const SOURCES: Record<string, Source> = { gemini: { kind: "provider", id: "google" }, xai: { kind: "provider", id: "xai" }, meta: { kind: "vector", id: "meta" }, + llama: { kind: "provider", id: "llama" }, openrouter: { kind: "provider", id: "openrouter" }, togetherai: { kind: "provider", id: "togetherai" }, groq: { kind: "provider", id: "groq" }, @@ -86,10 +87,28 @@ const SOURCES: Record<string, Source> = { langsmith: { kind: "vector", id: "langsmith" }, } +// Provider catalogs and OpenRouter vendor slugs do not always use the same id +// as the icon pack. Normalize the known spellings so a real provider never +// falls back to a generic initial (notably DeepSeek, Kimi, and Z.AI). +const ALIASES: Record<string, keyof typeof SOURCES> = { + "deep-seek": "deepseek", + "deepseek-ai": "deepseek", + together: "togetherai", + fireworks: "fireworks-ai", + moonshot: "moonshotai", + kimi: "moonshotai", + "z-ai": "zai", + zhipuai: "zai", + "x-ai": "xai", + "google-gemini": "google", + "meta-llama": "llama", +} + export const PROVIDER_LOGO_IDS = Object.keys(SOURCES) export function providerLogoSource(id: string) { - return SOURCES[id] ?? ({ kind: "fallback" } as const) + const normalized = id.trim().toLowerCase() + return SOURCES[ALIASES[normalized] ?? normalized] ?? ({ kind: "fallback" } as const) } const Mark: Component<{ source: Source; label: string; small?: boolean }> = (props) => { @@ -113,7 +132,7 @@ const Mark: Component<{ source: Source; label: string; small?: boolean }> = (pro ) } return ( - <span class={props.small ? "text-[9px] font-semibold" : "text-11-medium"}> + <span class={props.small ? "text-[9px] font-medium" : "text-11-medium"}> {props.label.trim().charAt(0).toUpperCase()} </span> ) @@ -130,8 +149,8 @@ export const ProviderLogo: Component<{ const source = () => providerLogoSource(props.id) return ( <span - class="relative flex shrink-0 items-center justify-center overflow-hidden border border-border-weak-base bg-surface-base text-text-strong" - classList={{ "size-6 rounded-[7px]": small(), "size-8 rounded-[9px]": !small() }} + class="settings-provider-logo" + data-size={small() ? "small" : "default"} role="img" aria-label={`${label()} logo`} data-provider-logo={props.id} diff --git a/frontend/workspace/src/components/settings/Sandbox.test.ts b/frontend/workspace/src/components/settings/Sandbox.test.ts new file mode 100644 index 00000000..21b14c1f --- /dev/null +++ b/frontend/workspace/src/components/settings/Sandbox.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test" + +const source = Bun.file(new URL("./Sandbox.tsx", import.meta.url)).text() +const styles = Bun.file(new URL("./sandbox.css", import.meta.url)).text() + +test("sandbox keeps active native backends available and their fixed policies truthful", async () => { + const component = await source + + expect(component).toContain('s().available ? "Available" : "Unavailable"') + expect(component).toContain('current.backend === "seatbelt" || current.backend === "bubblewrap"') + expect(component).toContain("const grantOnlyEnforced") + expect(component).toContain("const networkDenyEnforced") + expect(component).toContain('capability === "grant_only" || (capability === undefined && nativeBackendActive())') + expect(component).toContain('capability === "deny_all" || (capability === undefined && nativeBackendActive())') + expect(component).not.toContain('status()?.readIsolation === "grant_only" || nativeBackendActive()') + expect(component).not.toContain('status()?.networkIsolation === "deny_all" || nativeBackendActive()') + expect(component).toContain("Reads and writes are limited to the workspace and approved paths.") + expect(component).toContain("including loopback, LAN, link-local, and metadata endpoints") + expect(component).toContain('disabled={busy("network") || unavailable() || networkDenyEnforced()}') + expect(component).not.toContain('s().available && s().readIsolation === "grant_only" ? "Available"') +}) + +test("sandbox mutations respond optimistically while server writes stay ordered", async () => { + const component = await source + + expect(component).toContain("mutate({ ...current, config: { ...current.config, ...body } })") + expect(component).toContain("writeQueue.push({ body, key, failure })") + expect(component).toContain("while (writeQueue.length > 0)") + expect(component).toContain("config: pendingConfig(confirmed.config)") + expect(component).toContain('busy("enabled")') + expect(component).toContain('busy("network")') + expect(component).toContain('busy("fallback")') + expect(component).toContain('busy("paths")') + expect(component).not.toContain("disabled={busy()") +}) + +test("sandbox progressively discloses technical facts and self-test checks", async () => { + const component = await source + + expect(component).toContain("aria-controls={backendDetailsId}") + expect(component).toContain("aria-expanded={showBackendDetails()}") + expect(component).toContain("aria-controls={testDetailsId}") + expect(component).toContain("aria-expanded={showTestDetails()}") + expect(component).toContain('aria-live="polite"') + expect(component).toContain('call<SelfTest>("/test", { method: "POST" })') +}) + +test("sandbox removes redundant icon columns and owns explicit narrow layouts", async () => { + const component = await source + const css = await styles + + expect(component).not.toContain('name="shield"') + expect(component).not.toContain('name="shield-alert"') + expect(component).not.toContain('name="folder"') + expect(component).not.toContain('name="plus"') + expect(component).not.toContain('name="checklist"') + expect(css).toContain("grid-template-columns: minmax(0, 1fr) max-content") + expect(css).toContain("@container settings-main (max-width: 520px)") + expect(css).toContain("min-height: 32px") + expect(css).toContain("150ms ease") + expect(css).toContain(":focus-visible") + expect(css).toContain("transition: none") +}) diff --git a/frontend/workspace/src/components/settings/Sandbox.tsx b/frontend/workspace/src/components/settings/Sandbox.tsx index 1fa98530..b6d80304 100644 --- a/frontend/workspace/src/components/settings/Sandbox.tsx +++ b/frontend/workspace/src/components/settings/Sandbox.tsx @@ -1,11 +1,11 @@ // Execution sandbox settings — the permission system decides *whether* the agent // runs a shell command; it is not an isolation boundary. This panel turns on a -// real one: macOS Seatbelt / Linux bubblewrap that confines the agent's writes -// to the workspace and can deny network egress. The server +// real one: native containment that confines reads and writes to granted paths +// and denies network egress. The server // (routes/settings/sandbox.ts) reports backend availability, persists the // config, and runs the empirical self-test the browser can't. Mirrors the // `openscience sandbox` CLI. -import { Component, For, Show, createResource, createSignal } from "solid-js" +import { Component, For, Show, createResource, createSignal, createUniqueId } from "solid-js" import { Select } from "@synsci/ui/select" import { Button } from "@synsci/ui/button" import { Switch } from "@synsci/ui/switch" @@ -14,6 +14,9 @@ import { showToast } from "@synsci/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" import { settingsApi } from "./api" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./preference-panels.css" +import "./sandbox.css" interface SandboxConfig { enabled?: boolean @@ -25,6 +28,8 @@ interface Status { platform: string backend: "seatbelt" | "bubblewrap" | "none" available: boolean + readIsolation?: "grant_only" | "unavailable" + networkIsolation?: "deny_all" | "unavailable" tool?: string reason?: string } @@ -45,6 +50,9 @@ interface SelfTest { ok: boolean } +type WriteKey = "enabled" | "network" | "fallback" | "paths" +type PendingWrite = { body: SandboxConfig; key: WriteKey; failure: string } + const NETWORK_OPTS = [ { value: "allow" as const, label: "Allow" }, { value: "deny" as const, label: "Deny" }, @@ -63,34 +71,100 @@ const Sandbox: Component = () => { settingsApi<T>(sdk.url, fetchFn, `/settings/sandbox${path}`, init) const [data, { mutate, refetch }] = createResource(() => call<Payload>("")) - const [busy, setBusy] = createSignal(false) + const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<WriteKey>>(new Set()) + const [saving, setSaving] = createSignal(false) const [test, setTest] = createSignal<SelfTest>() const [testing, setTesting] = createSignal(false) const [newPath, setNewPath] = createSignal("") + const [showBackendDetails, setShowBackendDetails] = createSignal(false) + const [showPathEditor, setShowPathEditor] = createSignal(false) + const [showTestDetails, setShowTestDetails] = createSignal(false) + const backendDetailsId = `sandbox-backend-${createUniqueId()}` + const pathEditorId = `sandbox-path-${createUniqueId()}` + const testDetailsId = `sandbox-test-${createUniqueId()}` + const writeQueue: PendingWrite[] = [] + let writeLoop: Promise<void> | undefined const config = (): SandboxConfig => data()?.config ?? { enabled: true, network: "deny", allowWrite: [], onUnavailable: "error" } const status = () => data()?.status + const unavailable = () => data.loading || !!data.error + const busy = (key: WriteKey) => busyKeys().has(key) + const setKeyBusy = (key: WriteKey, value: boolean) => { + setBusyKeys((current) => { + const next = new Set(current) + if (value) next.add(key) + else next.delete(key) + return next + }) + } + // Older running servers may omit the two explicit capability fields while + // still reporting a supported native backend. Seatbelt and bubblewrap have + // fixed grant-only / deny-all semantics, so keep the UI truthful across the + // rolling frontend/backend upgrade instead of labelling an active backend + // unavailable or presenting its forced Deny policy as configurable. + const nativeBackendActive = () => { + const current = status() + return !!current?.available && (current.backend === "seatbelt" || current.backend === "bubblewrap") + } + const grantOnlyEnforced = () => { + const capability = status()?.readIsolation + return capability === "grant_only" || (capability === undefined && nativeBackendActive()) + } + const networkDenyEnforced = () => { + const capability = status()?.networkIsolation + return capability === "deny_all" || (capability === undefined && nativeBackendActive()) + } + const effectiveNetwork = () => (networkDenyEnforced() ? "deny" : (config().network ?? "deny")) + const pendingConfig = (base: SandboxConfig) => + writeQueue.reduce((current, item) => ({ ...current, ...item.body }), base) - const patch = async (body: SandboxConfig, failure: string) => { - setBusy(true) - try { - mutate(await call<Payload>("", { method: "PUT", body: JSON.stringify(body) })) - } catch (err) { - showToast({ title: failure, description: err instanceof Error ? err.message : String(err) }) - refetch() + const drainWrites = async () => { + setSaving(true) + while (writeQueue.length > 0) { + const item = writeQueue.shift()! + try { + const confirmed = await call<Payload>("", { method: "PUT", body: JSON.stringify(item.body) }) + // The response confirms this write. Re-apply later optimistic edits so + // an older response never makes another control appear to jump back. + mutate({ ...confirmed, config: pendingConfig(confirmed.config) }) + } catch (err) { + const abandoned = writeQueue.splice(0) + for (const pending of abandoned) setKeyBusy(pending.key, false) + showToast({ title: item.failure, description: err instanceof Error ? err.message : String(err) }) + void refetch() + break + } finally { + setKeyBusy(item.key, false) + } } - setBusy(false) + setSaving(false) + writeLoop = undefined + } + + const patch = (body: SandboxConfig, key: WriteKey, failure: string) => { + if (unavailable() || busy(key)) return + const current = data() + if (!current) return + + // Apply feedback immediately, but serialize server writes. Other controls + // remain usable; only the preference being committed is temporarily held. + mutate({ ...current, config: { ...current.config, ...body } }) + setKeyBusy(key, true) + writeQueue.push({ body, key, failure }) + writeLoop ??= drainWrites() } const runTest = async () => { + if (testing()) return setTesting(true) try { setTest(await call<SelfTest>("/test", { method: "POST" })) } catch (err) { showToast({ title: "Self-test failed to run", description: err instanceof Error ? err.message : String(err) }) + } finally { + setTesting(false) } - setTesting(false) } const addPath = () => { @@ -103,190 +177,335 @@ const Sandbox: Component = () => { const next = [...(config().allowWrite ?? [])] if (!next.includes(p)) next.push(p) setNewPath("") - patch({ allowWrite: next }, "Couldn't add the path") + setShowPathEditor(false) + patch({ allowWrite: next }, "paths", "Couldn't add the path") } const removePath = (p: string) => - patch({ allowWrite: (config().allowWrite ?? []).filter((x) => x !== p) }, "Couldn't remove the path") + patch({ allowWrite: (config().allowWrite ?? []).filter((x) => x !== p) }, "paths", "Couldn't remove the path") return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Execution sandbox</h2> - <p class="text-13-regular text-text-weak"> - Permissions decide <em>whether</em> the agent runs a shell command — not what it can reach once it does. - OpenScience confines local terminals, kernels, and shell commands by default: writes are limited to - authorized project roots and network egress is denied unless you explicitly relax the machine-wide policy. - </p> - </div> - </div> - - <div class="settings-page-body"> - {/* ── Backend availability ── */} - <Show when={status()}> - {(s) => ( - <div - class="flex items-center gap-2 rounded-[4px] border px-4 py-3 text-12-regular" - classList={{ - "border-border-weak-base bg-surface-base/40 text-text-weak": s().available, - "border-text-warning/30 bg-text-warning/5 text-text-warning": !s().available, - }} - > - <Icon name={s().available ? "check" : "stop"} class={s().available ? "text-text-success" : ""} /> - <Show - when={s().available} - fallback={ + <PanelScroll> + <div + class="settings-preferences-panel settings-preferences-panel--sandbox" + aria-busy={saving() ? "true" : undefined} + > + <PanelHeader + title="Sandbox" + description="Isolate local terminals, kernels, and shell commands." + toolbar={ + <span class="settings-sandbox-save-state" role="status" aria-live="polite"> + {saving() ? "Saving…" : ""} + </span> + } + /> + <PanelBody> + <Show when={data.error}> + <div class="settings-alert" data-tone="critical" role="alert"> + <span>Sandbox settings could not be loaded. {String(data.error)}</span> + <button + type="button" + class="settings-inline-action" + disabled={data.loading || saving()} + onClick={() => void refetch()} + > + Retry + </button> + </div> + </Show> + <Section + title="Protection" + description="Permissions approve a command; the sandbox limits what it can reach." + > + <div class="settings-card settings-preferences-card"> + <div class="settings-row settings-sandbox-control-row settings-sandbox-enable-row"> + <div class="settings-row-copy"> + <strong>Sandbox agent commands</strong> <span> - No sandbox backend on this machine ({s().platform}) — {s().reason}. + {config().enabled !== false + ? "On — reads and writes stay within the workspace and approved paths." + : "Off — commands run with your full user authority."} </span> - } + </div> + <Switch + hideLabel + checked={config().enabled !== false} + disabled={busy("enabled") || unavailable()} + onChange={(checked) => patch({ enabled: checked }, "enabled", "Couldn't update the sandbox setting")} + > + Sandbox agent commands + </Switch> + </div> + <Show + when={status()} + fallback={<div class="settings-panel-loading-copy">Checking native containment…</div>} > - <span> - Backend ready: <b>{s().backend}</b> via <code>{s().tool}</code> ({s().platform}). - </span> + {(s) => ( + <> + <button + type="button" + class="settings-row settings-sandbox-status-row" + aria-expanded={showBackendDetails()} + aria-controls={backendDetailsId} + onClick={() => setShowBackendDetails((value) => !value)} + > + <span + class="settings-sandbox-status-mark" + data-tone={s().available ? "success" : "warning"} + aria-hidden="true" + > + <Icon name={s().available ? "check" : "stop"} size="small" /> + </span> + <span class="settings-row-copy"> + <strong>{s().available ? "Native containment available" : "Sandbox unavailable"}</strong> + <span> + {s().available + ? `${s().backend} on ${s().platform}` + : `No supported backend on ${s().platform}`} + </span> + </span> + <span class="settings-preference-status" data-tone={s().available ? "success" : "warning"}> + {s().available ? "Available" : "Unavailable"} + </span> + <Icon + name="chevron-down" + size="small" + class="settings-sandbox-disclosure-icon" + classList={{ "settings-sandbox-disclosure-icon--open": showBackendDetails() }} + /> + </button> + <Show when={showBackendDetails()}> + <div id={backendDetailsId} class="settings-sandbox-backend-details"> + <dl> + <div> + <dt>Backend</dt> + <dd>{s().backend}</dd> + </div> + <div> + <dt>Platform</dt> + <dd>{s().platform}</dd> + </div> + <div> + <dt>Tool</dt> + <dd>{s().tool ?? "Not available"}</dd> + </div> + <div> + <dt>File access</dt> + <dd> + {grantOnlyEnforced() + ? "Reads and writes are limited to the workspace and approved paths." + : "Grant-only read isolation is unavailable."} + </dd> + </div> + <div> + <dt>Network</dt> + <dd> + {networkDenyEnforced() + ? "All network access is denied." + : "Network isolation is unavailable."} + </dd> + </div> + <Show when={s().reason}> + <div> + <dt>Reason</dt> + <dd>{s().reason}</dd> + </div> + </Show> + </dl> + </div> + </Show> + </> + )} </Show> </div> - )} - </Show> - - {/* ── Enable ── */} - <section class="flex flex-col gap-3"> - <div class="flex items-center justify-between border border-border-weak-base rounded-[4px] px-4 py-3.5 bg-surface-base/40"> - <div class="flex flex-col gap-0.5 min-w-0 pr-4"> - <span class="text-14-medium text-text-strong">Sandbox agent commands</span> - <span class="text-12-regular text-text-weak"> - {config().enabled !== false - ? "On — commands run confined to the workspace." - : "Off — commands run with your full user authority."} - </span> - </div> - <Switch - checked={config().enabled !== false} - disabled={busy()} - onChange={(checked) => patch({ enabled: checked }, "Couldn't update the sandbox setting")} - /> - </div> - </section> - - {/* ── Options (only when enabled) ── */} - <Show when={config().enabled !== false}> - <section class="flex flex-col gap-4"> - <h3 class="text-13-medium text-text-strong">Policy</h3> + </Section> - <div class="border border-border-weak-base rounded-[4px] overflow-hidden bg-surface-base/40"> - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5 border-b border-border-weak-base"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-14-medium text-text-strong">Network egress</span> - <span class="text-12-regular text-text-weak"> - Deny to stop sandboxed commands reaching the network. - </span> + <Show when={config().enabled !== false}> + <Section title="Policy" description="Choose the default containment policy for sandboxed commands."> + <div class="settings-card settings-preferences-card"> + <div class="settings-row settings-sandbox-control-row"> + <div class="settings-row-copy"> + <strong>Network access</strong> + <span> + {networkDenyEnforced() + ? "This backend always denies network access, including loopback, LAN, link-local, and metadata endpoints." + : "Allow permits outbound network access while loopback remains blocked."} + </span> + </div> + <Select + options={NETWORK_OPTS} + current={NETWORK_OPTS.find((o) => o.value === effectiveNetwork())} + value={(o) => o.value} + label={(o) => o.label} + disabled={busy("network") || unavailable() || networkDenyEnforced()} + onSelect={(o) => o && patch({ network: o.value }, "network", "Couldn't update network policy")} + variant="secondary" + size="small" + triggerVariant="settings" + /> </div> - <Select - options={NETWORK_OPTS} - current={NETWORK_OPTS.find((o) => o.value === (config().network ?? "deny"))} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(o) => o && patch({ network: o.value }, "Couldn't update network policy")} - variant="secondary" - size="small" - triggerVariant="settings" - /> - </div> - <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3.5"> - <div class="flex flex-col gap-0.5 min-w-0"> - <span class="text-14-medium text-text-strong">When no backend is available</span> - <span class="text-12-regular text-text-weak"> - On a machine with no sandbox (e.g. Windows), how to handle a command. - </span> + <div class="settings-row settings-sandbox-control-row"> + <div class="settings-row-copy"> + <strong>Fallback behavior</strong> + <span>Choose what happens if filesystem isolation cannot start.</span> + </div> + <Select + options={UNAVAILABLE_OPTS} + current={UNAVAILABLE_OPTS.find((o) => o.value === (config().onUnavailable ?? "error"))} + value={(o) => o.value} + label={(o) => o.label} + disabled={busy("fallback") || unavailable()} + onSelect={(o) => + o && patch({ onUnavailable: o.value }, "fallback", "Couldn't update fallback behavior") + } + variant="secondary" + size="small" + triggerVariant="settings" + /> </div> - <Select - options={UNAVAILABLE_OPTS} - current={UNAVAILABLE_OPTS.find((o) => o.value === (config().onUnavailable ?? "error"))} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(o) => o && patch({ onUnavailable: o.value }, "Couldn't update fallback behavior")} - variant="secondary" - size="small" - triggerVariant="settings" - /> </div> - </div> + </Section> - {/* extra writable paths */} - <div class="flex flex-col gap-2"> - <span class="text-13-medium text-text-strong">Extra writable paths</span> - <span class="text-12-regular text-text-weak/70"> - Absolute paths — beyond the workspace and temp dirs — the sandbox may write to. - </span> - <For each={config().allowWrite ?? []}> - {(p) => ( - <div class="flex items-center justify-between border border-border-weak-base rounded-[4px] px-3 py-2 bg-surface-base/40"> - <code class="text-12-regular text-text-strong truncate">{p}</code> - <Button size="small" variant="secondary" disabled={busy()} onClick={() => removePath(p)}> - <Icon name="trash" /> + <Section + title="Extra writable paths" + count={(config().allowWrite ?? []).length} + description="Absolute paths outside the workspace and temporary directories that the sandbox may modify." + > + <div class="settings-card settings-preferences-card"> + <Show + when={(config().allowWrite ?? []).length > 0} + fallback={ + <div class="settings-row settings-sandbox-control-row settings-sandbox-empty-paths"> + <div class="settings-row-copy"> + <strong>Workspace only</strong> + <span>No extra writable paths are approved.</span> + </div> + <button + type="button" + class="settings-preference-action" + aria-expanded={showPathEditor()} + aria-controls={pathEditorId} + disabled={busy("paths") || unavailable()} + onClick={() => setShowPathEditor((value) => !value)} + > + {showPathEditor() ? "Cancel" : "Add path"} + </button> + </div> + } + > + <For each={config().allowWrite ?? []}> + {(p) => ( + <div class="settings-row settings-sandbox-path-row"> + <code title={p}>{p}</code> + <button + type="button" + class="settings-preference-action" + data-variant="danger" + disabled={busy("paths")} + aria-label={`Remove writable path ${p}`} + onClick={() => removePath(p)} + > + Remove + </button> + </div> + )} + </For> + </Show> + <Show when={showPathEditor() || (config().allowWrite ?? []).length > 0}> + <div id={pathEditorId} class="settings-sandbox-path-editor"> + <input + class="settings-field" + aria-label="Add writable path" + placeholder="/absolute/path" + value={newPath()} + disabled={busy("paths") || unavailable()} + onInput={(e) => setNewPath(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && addPath()} + /> + <Button + class="settings-panel-action settings-panel-action--quiet" + size="small" + variant="secondary" + disabled={busy("paths") || unavailable() || !newPath().trim()} + onClick={addPath} + > + Add </Button> </div> - )} - </For> - <div class="flex items-center gap-2"> - <input - class="flex-1 bg-surface-base/40 border border-border-weak-base rounded-[4px] px-3 py-2 text-12-regular text-text-strong outline-none focus:border-border-strong" - placeholder="/absolute/path" - value={newPath()} - onInput={(e) => setNewPath(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && addPath()} - /> - <Button size="small" variant="secondary" disabled={busy() || !newPath().trim()} onClick={addPath}> - Add - </Button> + </Show> </div> - </div> + </Section> - {/* self-test */} - <div class="flex flex-col gap-3 border border-border-weak-base rounded-[4px] p-4 bg-surface-base/40"> - <div class="flex items-center justify-between gap-4"> - <div class="flex flex-col gap-0.5"> - <span class="text-14-medium text-text-strong">Verify containment</span> - <span class="text-12-regular text-text-weak"> - Runs real sandboxed commands to prove writes and network are actually confined. - </span> - </div> - <Button size="small" variant="secondary" disabled={testing() || !status()?.available} onClick={runTest}> - {testing() ? "Testing…" : "Run self-test"} - </Button> - </div> - <Show when={test()}> - {(t) => ( - <div class="flex flex-col gap-1.5 pt-1"> - <For each={t().checks}> - {(c) => ( - <div class="flex items-center gap-2 text-12-regular"> - <Icon - name={c.skipped ? "dash" : c.pass ? "check" : "close"} - class={c.skipped ? "text-text-weak" : c.pass ? "text-text-success" : "text-text-danger"} - /> - <span class="text-text-strong">{c.name}</span> - <Show when={c.detail}> - <span class="text-text-weak/70">— {c.detail}</span> - </Show> - </div> - )} - </For> - <span - class="text-12-medium pt-1" - classList={{ "text-text-success": t().ok, "text-text-danger": !t().ok }} - > - {t().ok ? "Containment verified." : "Containment failed — do not rely on the sandbox."} + <Section + title="Containment test" + description="Run real sandboxed commands to verify the boundaries this backend claims to enforce." + > + <div class="settings-card settings-preferences-card"> + <div class="settings-row settings-sandbox-control-row settings-sandbox-test-row"> + <div class="settings-row-copy"> + <strong>Verify containment</strong> + <span> + Tests read and write boundaries plus effective network isolation with real sandboxed commands. </span> </div> - )} - </Show> - </div> - </section> - </Show> + <Button + size="small" + variant="secondary" + disabled={testing() || unavailable() || !status()?.available} + onClick={runTest} + > + {testing() ? "Testing…" : "Run self-test"} + </Button> + </div> + <Show when={test()}> + {(t) => ( + <div class="settings-sandbox-result" aria-live="polite"> + <div class="settings-sandbox-result__summary" role="status"> + <span class="settings-sandbox-result__dot" data-tone={t().ok ? "success" : "danger"} /> + <span classList={{ "text-text-success": t().ok, "text-text-danger": !t().ok }}> + {t().ok ? "Containment verified." : "Containment failed — do not rely on the sandbox."} + </span> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + aria-expanded={showTestDetails()} + aria-controls={testDetailsId} + onClick={() => setShowTestDetails((value) => !value)} + > + {showTestDetails() ? "Hide checks" : `Show ${t().checks.length} checks`} + </button> + </div> + <Show when={showTestDetails()}> + <div id={testDetailsId} class="settings-sandbox-checks"> + <For each={t().checks}> + {(c) => ( + <div class="settings-sandbox-check"> + <Icon + name={c.skipped ? "dash" : c.pass ? "check" : "close"} + class={ + c.skipped ? "text-text-weak" : c.pass ? "text-text-success" : "text-text-danger" + } + size="small" + /> + <span>{c.name}</span> + <Show when={c.detail}> + <code class="settings-sandbox-check__detail">{c.detail}</code> + </Show> + </div> + )} + </For> + </div> + </Show> + </div> + )} + </Show> + </div> + </Section> + </Show> + </PanelBody> </div> - </div> + </PanelScroll> ) } diff --git a/frontend/workspace/src/components/settings/Skills.test.ts b/frontend/workspace/src/components/settings/Skills.test.ts index f508186e..02893f8a 100644 --- a/frontend/workspace/src/components/settings/Skills.test.ts +++ b/frontend/workspace/src/components/settings/Skills.test.ts @@ -38,22 +38,25 @@ const mount = (view: () => JSX.Element) => { return host } -test("skills settings frame renders compact catalog chrome", () => { +test("skills settings frame renders the catalog without injected style tags", () => { const host = mount(() => subject.SkillsFrame({ children: "Catalog content" })) const frame = host.querySelector<HTMLElement>(".settings-skills") expect(frame?.getAttribute("aria-label")).toBe("Skills settings") expect(frame?.textContent).toContain("Catalog content") - expect(frame?.querySelector("style")?.textContent).toContain(".settings-skills .skills-workspace__row") + expect(frame?.querySelector("style")).toBeNull() }) -test("embedded skills catalog omits nested workspace title chrome", () => { +test("embedded skills catalog uses a useful sentence-case heading and live actions", () => { const wrapper = readFileSync(fileURLToPath(new URL("./Skills.tsx", import.meta.url)), "utf8") const catalog = readFileSync(fileURLToPath(new URL("../../atlas/SkillsPage.tsx", import.meta.url)), "utf8") + const styles = readFileSync(fileURLToPath(new URL("./skills.css", import.meta.url)), "utf8") expect(wrapper).toContain("<SkillsPage embedded />") + expect(wrapper).toContain('import "./skills.css"') expect(catalog).toContain('data-layout={props.embedded ? "settings" : "workspace"}') - expect(catalog).toContain("<Show when={!props.embedded}>") + expect(catalog).toContain("when={!props.embedded}") + expect(catalog).toContain("<h2>Available skills</h2>") expect(catalog).toContain("sdk.client.app.skills()") expect(catalog).toContain("sdk.client.app.skill.write") expect(catalog).toContain("sync.updateConfig") @@ -63,4 +66,68 @@ test("embedded skills catalog omits nested workspace title chrome", () => { expect(catalog).toContain('label: "Import from GitHub"') expect(catalog).toContain('label="Cancel"') expect(catalog).not.toContain('label="cancel"') + expect(catalog).toContain('class="skills-workspace__rows"') + expect(catalog).not.toContain('class="skills-workspace__source-icon"') + expect(catalog).toContain('class="skills-workspace__details"') + expect(catalog).toContain('class="settings-chip"') + expect(catalog).toContain('action="Clear filters"') + expect(catalog).toContain("data-source={source()}") + expect(catalog).toContain('<ul class="skills-workspace__rows">') + expect(styles).toMatch(/\.settings-skills\s*\{[^}]*width: 100%[^}]*min-width: 0/s) + expect(styles).toMatch( + /\.settings-skills \.skills-workspace__rows\s*\{[^}]*padding: 4px;[^}]*border: 0;[^}]*background: var\(--settings-surface\)/s, + ) + expect(styles).toMatch( + /\.settings-skills \.skills-workspace__row\s*\{[^}]*border: 0;[^}]*border-radius: var\(--settings-radius-control\);[^}]*background: transparent/s, + ) + expect(styles).toMatch( + /\.settings-skills \.skills-workspace__form-fields \.settings-field,[^}]*\.skills-workspace__security-note\s*\{[^}]*border-color: transparent;[^}]*background: var\(--settings-surface\)/s, + ) + expect(styles).toMatch( + /\.settings-skills \.skills-workspace__heading,[^}]*\.settings-skills \.skills-workspace__content\s*\{[^}]*max-width: 900px/s, + ) + expect(styles).toMatch(/\.settings-skills \.skills-workspace__header\s*\{[^}]*border-bottom: 0/s) + expect(styles).toContain("@container skills-workspace (max-width: 960px)") + expect(styles).toMatch( + /\.settings-skills \.skills-workspace__toolbar-controls\s*\{[^}]*display: grid;[^}]*minmax\(148px, max-content\)[^}]*minmax\(168px, max-content\)/s, + ) + expect(styles).toMatch(/\.settings-skills \.skills-workspace \.settings-control\s*\{[^}]*height: 32px/s) + expect(styles).toContain("background: var(--settings-toggle-active)") + expect(catalog).toContain("INITIAL_SKILL_ROWS = 56") + expect(catalog).toContain("visibleShelves") + expect(catalog).toContain("permissionWrites = permissionWrites.then") + expect(catalog).not.toContain("permissionBusy") + expect(styles).not.toContain("outline-color: var(--settings-accent)") +}) + +test("skill icon helper remains available to picker surfaces", () => { + expect(subject.skillIconFor({ name: "protein-folding", category: "biology" })).toBe("braces") + expect(subject.skillIconFor({ name: "postgres", category: "databases" })).toBe("server") + expect(subject.skillIconFor({ name: "paper-review", category: "research" })).toBe("book-open") + expect(subject.skillIconFor({ name: "custom-thing" })).toBe(subject.skillIconFor({ name: "custom-thing" })) +}) + +test("embedded skills use the shared settings type rhythm without shrinking standalone Skills", () => { + const embedded = readFileSync(fileURLToPath(new URL("./skills.css", import.meta.url)), "utf8") + const standalone = readFileSync(fileURLToPath(new URL("../../atlas/skills-page.css", import.meta.url)), "utf8") + + expect(embedded).toMatch( + /\.settings-skills \.skills-workspace__heading h2\s*\{[^}]*font-size: var\(--settings-type-title\);[^}]*line-height: var\(--settings-leading-title\)/s, + ) + expect(embedded).toMatch( + /\.settings-skills \.skills-workspace__group-heading h3,[\s\S]*?\.settings-skills \.skills-workspace__state > strong\s*\{[^}]*font-size: var\(--settings-type-body\);[^}]*font-weight: var\(--font-weight-medium\);[^}]*line-height: var\(--settings-leading-body\)/s, + ) + expect(embedded).toMatch( + /\.settings-skills \.skills-workspace__group-heading > span,[\s\S]*?\.settings-skills \.skills-workspace__form-actions button\s*\{[^}]*font-size: var\(--settings-type-helper\);[^}]*line-height: var\(--settings-leading-helper\)/s, + ) + expect(embedded).toMatch( + /\.settings-skills \.skills-workspace__tags \.settings-chip\s*\{[^}]*min-height: 20px;[^}]*font-size: var\(--settings-type-helper\);[^}]*line-height: var\(--settings-leading-helper\)/s, + ) + expect(embedded).toMatch( + /@container skills-workspace \(max-width: 640px\)[\s\S]*?\.settings-skills \.skills-workspace__tags\s*\{[^}]*display: flex/s, + ) + + expect(standalone).toMatch(/\.skills-workspace__heading h1\s*\{[^}]*font-size: 24px/s) + expect(standalone).toMatch(/\.skills-workspace__group-heading h3\s*\{[^}]*font-size: 14px/s) + expect(standalone).toMatch(/\.skills-workspace__identity strong\s*\{[^}]*font-size: 14px/s) }) diff --git a/frontend/workspace/src/components/settings/Skills.tsx b/frontend/workspace/src/components/settings/Skills.tsx index 15bf2646..829be872 100644 --- a/frontend/workspace/src/components/settings/Skills.tsx +++ b/frontend/workspace/src/components/settings/Skills.tsx @@ -1,51 +1,11 @@ import { type ParentComponent } from "solid-js" import SkillsPage from "@/atlas/SkillsPage" +import "./skills.css" -const STYLE = ` -.settings-skills { - display: flex; - height: 100%; - min-height: 0; - overflow: hidden; -} -.settings-skills .skills-workspace { - background: transparent; -} -.settings-skills .skills-workspace__header { - padding: 16px 24px 12px; - background: transparent; -} -.settings-skills .skills-workspace__summary { - margin-bottom: 10px; -} -.settings-skills .skills-workspace__body { - padding: 0 24px 32px; -} -.settings-skills .skills-workspace__content { - width: 100%; - max-width: 820px; - margin: 0; -} -.settings-skills .skills-workspace__row { - grid-template-columns: minmax(120px, 0.65fr) minmax(180px, 1.5fr) minmax(80px, 0.6fr) auto; - gap: 12px; -} -@media (max-width: 760px) { - .settings-skills .skills-workspace__header { - padding: 12px 16px 10px; - } - .settings-skills .skills-workspace__body { - padding: 0 16px 28px; - } - .settings-skills .skills-workspace__row { - grid-template-columns: minmax(0, 1fr) auto; - } -} -` +export { skillIconFor } from "@/atlas/SkillsPage" export const SkillsFrame: ParentComponent = (props) => ( <section class="settings-skills" aria-label="Skills settings"> - <style>{STYLE}</style> {props.children} </section> ) diff --git a/frontend/workspace/src/components/settings/Specialists.test.ts b/frontend/workspace/src/components/settings/Specialists.test.ts index 6d73ac69..1ee169f1 100644 --- a/frontend/workspace/src/components/settings/Specialists.test.ts +++ b/frontend/workspace/src/components/settings/Specialists.test.ts @@ -1,9 +1,31 @@ -import { describe, expect, test } from "bun:test" +import { afterAll, describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" -import { isVisibleSpecialist } from "./specialist-catalog" +import { createServer } from "vite" +import solid from "vite-plugin-solid" +import { SPECIALIST_GROUPS, isVisibleSpecialist, specialistGroupFor } from "./specialist-catalog" + +const server = await createServer({ + root: fileURLToPath(new URL("../../..", import.meta.url)), + mode: "production", + logLevel: "silent", + plugins: [solid({ ssr: false, dev: false })], + server: { middlewareMode: true }, + appType: "custom", + resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, + ssr: { + noExternal: true, + resolve: { conditions: ["browser", "production"] }, + }, +}) +const subject = (await server.ssrLoadModule( + "/src/components/settings/Specialists.tsx", +)) as typeof import("./Specialists") + +afterAll(() => server.close()) const source = () => readFileSync(fileURLToPath(new URL("./Specialists.tsx", import.meta.url)), "utf8") +const styles = () => readFileSync(fileURLToPath(new URL("./specialists.css", import.meta.url)), "utf8") describe("specialist catalog", () => { test("shows built-in subagents while hiding implementation agents and plan mode", () => { @@ -19,10 +41,129 @@ describe("specialist catalog", () => { test("uses sentence case for specialist actions", () => { const specialists = source() - expect(specialists).toContain('label="Add specialist"') - expect(specialists).toContain('label: "Write from scratch"') + expect(specialists).toContain("<span>Add specialist</span>") expect(specialists).toContain('"Create specialist"') expect(specialists).toContain('label="Cancel"') expect(specialists).not.toContain('label="add specialist"') + expect(specialists).not.toContain("<AddMenu") + }) + + test("uses a restrained semantic icon set and real delegation controls", () => { + const specialists = source() + + expect(specialists).toContain('biology: "flask"') + expect(specialists).toContain('physics: "atom"') + expect(specialists).toContain('"literature-review": "book-open"') + expect(specialists).toContain('data-action="specialist-delegation"') + expect(specialists).toContain('data-action="specialist-reviewer"') + expect(specialists).toContain('fallback={<span class="specialists-agent__session-only">Always available</span>}') + expect(specialists).toContain("<SpecialistIcon icon={icon()} />") + expect(specialists).toContain('class="specialists-agent__availability"') + expect(specialists).not.toContain("<Icon name={mode().icon}") + expect(specialists).not.toContain('<SpecialistIcon icon="glasses" />') + expect(specialists).not.toContain("monogram={label().slice(0, 1)}") + expect(subject.specialistIconFor({ name: "biology" })).toBe("flask") + expect(subject.specialistIconFor({ name: "custom-code-reviewer", description: "Audits software changes" })).toBe( + "code", + ) + expect(subject.specialistIconFor({ name: "unclassified-specialist" })).toBe("task") + }) + + test("uses the same compact filter menu as the rest of settings", () => { + const specialists = source() + + expect(specialists).toContain("<FilterMenu") + expect(specialists).toContain('ariaLabel="Filter specialists by mode"') + expect(specialists).toContain('label: "Session"') + expect(specialists).toContain('label: "Delegated"') + expect(subject.matchesModeFilter("primary", "primary")).toBe(true) + expect(subject.matchesModeFilter("all", "primary")).toBe(true) + expect(subject.matchesModeFilter("subagent", "primary")).toBe(false) + expect(subject.matchesModeFilter("subagent", "subagent")).toBe(true) + expect(subject.matchesModeFilter("all", "subagent")).toBe(true) + expect(subject.matchesModeFilter("primary", "subagent")).toBe(false) + }) + + test("explains specialist availability without implementation language", () => { + expect(subject.specialistModeMeta("primary")).toEqual({ label: "Session", canDelegate: false }) + expect(subject.specialistModeMeta("subagent")).toEqual({ + label: "Delegated", + canDelegate: true, + }) + expect(subject.specialistModeMeta("all")).toEqual({ + label: "Session + delegated", + canDelegate: true, + }) + }) + + test("segregates built-ins by research role rather than one undifferentiated list", () => { + const specialists = source() + + expect(SPECIALIST_GROUPS.map((group) => group.title)).toEqual([ + "Research", + "Review and writing", + "Workspace support", + ]) + expect(specialistGroupFor({ name: "biology" })).toBe("research") + expect(specialistGroupFor({ name: "critique" })).toBe("review") + expect(specialistGroupFor({ name: "literature-review" })).toBe("review") + expect(specialistGroupFor({ name: "explore" })).toBe("workspace") + expect(specialists).toContain('title="Specialist catalog"') + expect(specialists).toContain('class="specialists-catalog"') + expect(specialists).toContain('class="settings-card specialists-group__list" role="list"') + }) + + test("uses flat Apple-like groups with tactile, accessible controls", () => { + const css = styles() + + expect(css).toMatch(/\.specialists-panel \.settings-card\s*\{[^}]*gap: 2px;[^}]*padding: 4px;[^}]*border: 0/s) + expect(css).toMatch( + /\.specialists-panel \.settings-row\.specialists-agent\s*\{[^}]*display: grid;[^}]*grid-template-columns: 28px minmax\(0, 1fr\) max-content max-content;[^}]*border-radius: var\(--settings-radius-control\);[^}]*background: transparent/s, + ) + expect(css).toMatch(/\.specialists-panel \.settings-row\.specialists-agent\s*\{[^}]*background-color 150ms ease/s) + expect(css).toMatch(/@container specialists-panel \(max-width: 360px\)[\s\S]*grid-column: 2 \/ -1/) + expect(css).toContain("min-height: 44px") + expect(css).toContain("@media (prefers-reduced-motion: reduce)") + expect(css).toMatch(/\.specialists-panel \.settings-control\s*\{[^}]*height: 32px/s) + expect(css).not.toContain("var(--settings-accent)") + expect(css).not.toContain(".specialists-agent + .specialists-agent::before") + }) + + test("specialist delegation rules preserve wildcard and unrelated permissions", () => { + const before = { + bash: "ask", + task: { "*": "deny", biology: "allow" }, + } + + expect(subject.taskAction(before, "biology")).toBe("allow") + expect(subject.taskAction(before, "physics")).toBe("deny") + expect(subject.taskAction({ task: "ask" }, "biology")).toBe("ask") + expect(subject.taskAction("deny", "biology")).toBe("deny") + expect(subject.taskAction(undefined, "biology")).toBe("allow") + expect(subject.taskAction({ task: { "bio*": "deny", "*": "allow" } }, "biology")).toBe("allow") + expect(subject.taskAction({ task: { "*": "allow", "bio*": "deny" } }, "biology")).toBe("deny") + + const change = subject.taskPermissionChange(before, "physics", true) + expect((change.optimistic as Record<string, unknown>).bash).toBe("ask") + expect(change.optimistic.task).toEqual({ "*": "deny", biology: "allow", physics: "allow" }) + expect(change.patch).toEqual({ + task: { "*": "deny", biology: "allow", physics: "allow" }, + }) + expect( + subject.taskAction(subject.taskPermissionChange(change.optimistic, "biology", false).optimistic, "biology"), + ).toBe("deny") + const ordered = subject.taskPermissionChange({ task: { biology: "allow", "*": "allow" } }, "biology", false) + expect(Object.keys(ordered.patch.task)).toEqual(["*", "biology"]) + expect(subject.taskAction(ordered.optimistic, "biology")).toBe("deny") + const global = subject.taskPermissionChange("deny", "biology", true) + expect((global.optimistic as Record<string, unknown>)["*"]).toBe("deny") + expect(global.optimistic.task).toEqual({ biology: "allow" }) + expect( + subject.restoreExactTaskPermission( + { task: { "*": "allow", biology: "deny", physics: "deny" } }, + { task: { "*": "allow", biology: "allow" } }, + "biology", + ).task, + ).toEqual({ "*": "allow", biology: "allow", physics: "deny" }) }) }) diff --git a/frontend/workspace/src/components/settings/Specialists.tsx b/frontend/workspace/src/components/settings/Specialists.tsx index cae1c779..ce6a94c9 100644 --- a/frontend/workspace/src/components/settings/Specialists.tsx +++ b/frontend/workspace/src/components/settings/Specialists.tsx @@ -1,7 +1,11 @@ -import { For, Show, createMemo, createResource, createSignal } from "solid-js" +import { For, Show, createMemo, createResource, createSignal, type Component, type ParentComponent } from "solid-js" +import { Icon } from "@synsci/ui/icon" +import type { IconProps } from "@synsci/ui/icon" import { IconButton } from "@synsci/ui/icon-button" import { Switch } from "@synsci/ui/switch" import { showToast } from "@synsci/ui/toast" +import { useDialog } from "@synsci/ui/context/dialog" +import { confirmDialog } from "@/atlas/dialogs" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { usePlatform } from "@/context/platform" @@ -11,20 +15,17 @@ import { PanelScroll, PanelHeader, PanelBody, + Section, + Card, Toolbar, SearchInput, FilterMenu, - AddMenu, - Card, - Row, - SectionLabel, EmptyState, FormField, FormButton, - Avatar, - Chip, } from "./_shared" -import { isVisibleSpecialist } from "./specialist-catalog" +import { SPECIALIST_GROUPS, isVisibleSpecialist, specialistGroupFor } from "./specialist-catalog" +import "./specialists.css" const LABELS: Record<string, string> = { research: "Research", @@ -40,7 +41,22 @@ const LABELS: Record<string, string> = { "physics-critique": "Physics critique", reviewer: "Research reviewer", } +const ICONS = { + research: "models", + ml: "cpu", + biology: "flask", + physics: "atom", + write: "pencil-line", + docs: "file", + task: "task", + explore: "magnifying-glass", + "literature-review": "book-open", + critique: "shield-alert", + "physics-critique": "atom", + reviewer: "glasses", +} as const type Mode = "primary" | "subagent" | "all" +type ModeFilter = "all" | "primary" | "subagent" type ReviewPreferences = { auto: boolean model: { providerID: string; modelID: string } | null @@ -51,6 +67,7 @@ export default function Specialists() { const globalSDK = useGlobalSDK() const sync = useGlobalSync() const platform = usePlatform() + const dialog = useDialog() // Reviewer preference — GET/PUT /settings/review (backend/cli/src/settings/ // review.ts). Manual review stays always available from the session header; @@ -59,19 +76,23 @@ export default function Specialists() { const reviewApi = (init?: RequestInit) => settingsApi<ReviewPreferences>(sdk.url, fetchFn, "/settings/review", init) const [reviewPrefs, reviewCtl] = createResource(() => reviewApi()) const [reviewSaving, setReviewSaving] = createSignal(false) + let reviewVersion = 0 async function toggleAutoReview(auto: boolean) { + const before = reviewPrefs() + const version = ++reviewVersion + reviewCtl.mutate({ auto, model: before?.model ?? null }) setReviewSaving(true) try { - reviewCtl.mutate( - await reviewApi({ - method: "PUT", - body: JSON.stringify({ auto, model: reviewPrefs()?.model ?? null }), - }), - ) + const saved = await reviewApi({ + method: "PUT", + body: JSON.stringify({ auto, model: before?.model ?? null }), + }) + if (version === reviewVersion) reviewCtl.mutate(saved) } catch (err) { + if (version === reviewVersion && before) reviewCtl.mutate(before) showToast({ variant: "error", title: "Could not update reviewer preference", description: message(err) }) } finally { - setReviewSaving(false) + if (version === reviewVersion) setReviewSaving(false) } } @@ -84,39 +105,61 @@ export default function Specialists() { }) const [search, setSearch] = createSignal("") - const [modeFilter, setModeFilter] = createSignal("all") + const [modeFilter, setModeFilter] = createSignal<ModeFilter>("all") const [creating, setCreating] = createSignal(false) const [busy, setBusy] = createSignal(false) + const [delegationPending, setDelegationPending] = createSignal<Record<string, number>>({}) + const delegationVersions = new Map<string, number>() + let delegationWrites = Promise.resolve() const visible = createMemo(() => { const q = search().trim().toLowerCase() const m = modeFilter() return (agents() ?? []) - .filter((a) => m === "all" || a.mode === m || (m === "primary" && a.mode === "all")) + .filter((a) => matchesModeFilter(a.mode as Mode, m)) .filter((a) => !q || a.name.toLowerCase().includes(q) || (a.description ?? "").toLowerCase().includes(q)) }) - const builtIn = createMemo(() => - visible() - .filter((a) => a.native) - .sort(byName), + const builtInGroups = createMemo(() => + SPECIALIST_GROUPS.map((group) => ({ + ...group, + agents: visible() + .filter((agent) => agent.native && specialistGroupFor(agent) === group.id) + .sort(byName), + })).filter((group) => group.agents.length > 0), ) const custom = createMemo(() => visible() .filter((a) => !a.native) .sort(byName), ) + const delegated = (name: string) => taskAction(sync.data.config.permission, name) !== "deny" - const modeOptions = createMemo(() => [ - { id: "all", label: "All", count: (agents() ?? []).length }, - { - id: "primary", - label: "Primary", - count: (agents() ?? []).filter((a) => a.mode === "primary" || a.mode === "all").length, - }, - { id: "subagent", label: "Subagents", count: (agents() ?? []).filter((a) => a.mode === "subagent").length }, - ]) + const modeOptions = createMemo( + () => + [ + { id: "all", label: "All", count: (agents() ?? []).length }, + { + id: "primary", + label: "Session", + count: (agents() ?? []).filter((a) => a.mode === "primary" || a.mode === "all").length, + }, + { + id: "subagent", + label: "Delegated", + count: (agents() ?? []).filter((a) => a.mode === "subagent" || a.mode === "all").length, + }, + ] satisfies Array<{ id: ModeFilter; label: string; count: number }>, + ) async function createAgent(name: string, description: string, prompt: string, mode: Mode) { + if ((agents() ?? []).some((agent) => agent.name === name)) { + showToast({ + variant: "error", + title: "Specialist already exists", + description: `Choose a name other than "${name}".`, + }) + return + } setBusy(true) try { const agent: Config["agent"] = { [name]: { description, prompt: prompt || undefined, mode } } @@ -132,7 +175,13 @@ export default function Specialists() { } async function deleteAgent(name: string) { - if (!window.confirm(`Delete custom specialist "${name}"? This removes it from your config.`)) return + const confirmed = await confirmDialog(dialog, { + title: `Delete "${name}"?`, + message: "This removes the custom specialist from your global OpenScience configuration. This cannot be undone.", + confirmLabel: "Delete specialist", + danger: true, + }) + if (!confirmed) return setBusy(true) try { await globalSDK.client.global.configUnset({ path: ["agent", name] }) @@ -145,123 +194,296 @@ export default function Specialists() { } } - return ( - <PanelScroll> - <PanelHeader - title="Specialists" - description="Primary agents can lead a session. Subagents are delegated focused work by a primary agent. Every non-hidden built-in specialist appears here." - toolbar={ - <Show when={!creating()}> - <Toolbar> - <FilterMenu options={modeOptions()} value={modeFilter()} onSelect={setModeFilter} /> - <SearchInput value={search()} onInput={setSearch} placeholder="Search specialists" /> - <AddMenu - label="Add specialist" - items={[ - { - icon: "pencil-line", - label: "Write from scratch", - description: "Define a custom agent persisted to config", - onSelect: () => setCreating(true), - }, - ]} - /> - </Toolbar> - </Show> + function markDelegationPending(name: string, delta: number) { + setDelegationPending((current) => { + const next = { ...current } + const count = (next[name] ?? 0) + delta + if (count > 0) next[name] = count + else delete next[name] + return next + }) + } + + function toggleDelegation(name: string, enabled: boolean) { + const before = sync.data.config.permission + const change = taskPermissionChange(before, name, enabled) + const version = (delegationVersions.get(name) ?? 0) + 1 + delegationVersions.set(name, version) + sync.set("config", "permission", change.optimistic as Config["permission"]) + markDelegationPending(name, 1) + + const persist = async () => { + try { + const latest = taskPermissionChange(sync.data.config.permission, name, enabled) + await sync.updateConfig({ permission: latest.patch } as Config) + } catch (err) { + if (delegationVersions.get(name) === version) { + sync.set( + "config", + "permission", + restoreExactTaskPermission(sync.data.config.permission, before, name) as Config["permission"], + ) } - /> + showToast({ variant: "error", title: "Could not update specialist", description: message(err) }) + } finally { + markDelegationPending(name, -1) + } + } + delegationWrites = delegationWrites.then(persist, persist) + } - <PanelBody> - <Show when={creating()}> - <CreateForm busy={busy()} onCancel={() => setCreating(false)} onCreate={createAgent} /> - </Show> + return ( + <div class="specialists-panel"> + <PanelScroll> + <PanelHeader + title="Specialists" + description="Choose who can join a session or be delegated focused research work." + toolbar={ + <Show when={!creating()}> + <Toolbar> + <SearchInput + value={search()} + onInput={setSearch} + placeholder="Search specialists" + ariaLabel="Search specialists" + /> + <FilterMenu + options={modeOptions()} + value={modeFilter()} + onSelect={(mode) => setModeFilter(mode as ModeFilter)} + ariaLabel="Filter specialists by mode" + /> + <button + type="button" + class="settings-control settings-control--primary specialists-panel__add" + onClick={() => setCreating(true)} + > + <Icon name="plus" size="small" aria-hidden="true" /> + <span>Add specialist</span> + </button> + </Toolbar> + </Show> + } + /> - <Show when={!creating()}> - <div class="flex flex-col gap-2"> - <SectionLabel label="Reviewer" /> - <Card> - <Row> - <div class="min-w-0 flex-1"> - <span class="text-14-medium text-text-strong">Automatically review significant results</span> - <p class="text-12-regular text-text-weak mt-0.5"> - Runs the reviewer after a result is saved as a durable artifact. - </p> - </div> - <Switch - hideLabel - checked={reviewPrefs()?.auto ?? false} - disabled={reviewSaving() || reviewPrefs.loading} - onChange={(auto) => void toggleAutoReview(auto)} + <PanelBody> + <Show when={creating()}> + <CreateForm busy={busy()} onCancel={() => setCreating(false)} onCreate={createAgent} /> + </Show> + + <Show when={!creating()}> + <Show when={reviewPrefs.error}> + <div class="settings-alert" data-tone="critical" role="alert"> + <span>Reviewer settings could not be loaded. {message(reviewPrefs.error)}</span> + <button + type="button" + class="settings-inline-action" + disabled={reviewPrefs.loading || reviewSaving()} + onClick={() => void reviewCtl.refetch()} > - Automatically review significant results - </Switch> - </Row> - </Card> - </div> + Retry + </button> + </div> + </Show> + <Section + id="specialists-reviewer-heading" + title="Review automation" + description="Run an independent review after a significant result is saved." + > + <Card> + <div + class="settings-row specialists-agent specialists-agent--reviewer" + data-loading={reviewPrefs.loading ? "true" : undefined} + aria-busy={reviewPrefs.loading || reviewSaving() ? "true" : undefined} + > + <div class="specialists-agent__copy"> + <strong>Automatic result review</strong> + <p>Checks durable results without interrupting the active research session.</p> + </div> + <div class="specialists-agent__availability"> + <span class="specialists-agent__mode">{reviewPrefs.loading ? "Loading…" : "After save"}</span> + <div class="specialists-agent__control"> + <Switch + data-action="specialist-reviewer" + hideLabel + checked={reviewPrefs()?.auto ?? false} + disabled={reviewSaving() || reviewPrefs.loading || !!reviewPrefs.error} + onChange={(auto) => void toggleAutoReview(auto)} + > + Automatically review significant results + </Switch> + </div> + </div> + </div> + </Card> + </Section> - <Show - when={!agents.loading} - fallback={<div class="py-12 text-center text-13-regular text-text-weak">Loading specialists…</div>} - > <Show - when={visible().length > 0} - fallback={ - <EmptyState - icon="models" - title={search() ? "No matching specialists" : "No specialists"} - hint="Create a custom specialist to tailor an agent to your workflow." - /> - } + when={!agents.loading} + fallback={<div class="py-12 text-center text-13-regular text-text-weak">Loading specialists…</div>} > - <Show when={custom().length > 0}> - <div class="flex flex-col gap-2"> - <SectionLabel label="Custom" count={custom().length} /> - <Card> - <For each={custom()}> - {(agent) => ( - <AgentRow agent={agent} onDelete={() => void deleteAgent(agent.name)} busy={busy()} /> - )} - </For> - </Card> - </div> - </Show> + <Show + when={!agents.error} + fallback={ + <div class="settings-alert" data-tone="critical" role="alert"> + <span>Specialists could not be loaded. {message(agents.error)}</span> + <button type="button" class="settings-inline-action" onClick={() => void agentsCtl.refetch()}> + Retry + </button> + </div> + } + > + <Show + when={visible().length > 0} + fallback={ + <EmptyState + icon="task" + title={search() ? "No matching specialists" : "No specialists"} + hint="Create a custom specialist to tailor an agent to your workflow." + /> + } + > + <Section + id="specialists-catalog-heading" + title="Specialist catalog" + description="Roles are grouped by the work they handle. Delegation switches control automatic use." + count={visible().length} + > + <div class="specialists-catalog"> + <Show when={custom().length > 0}> + <SpecialistGroup + title="Custom roles" + description="Roles defined in your OpenScience configuration." + count={custom().length} + id="specialists-custom-heading" + > + <For each={custom()}> + {(agent) => ( + <AgentRow + agent={agent} + delegated={delegated(agent.name)} + onDelegation={(enabled) => toggleDelegation(agent.name, enabled)} + onDelete={() => void deleteAgent(agent.name)} + busy={busy()} + saving={Boolean(delegationPending()[agent.name])} + /> + )} + </For> + </SpecialistGroup> + </Show> - <Show when={builtIn().length > 0}> - <div class="flex flex-col gap-2"> - <SectionLabel label="Built-in" count={builtIn().length} /> - <Card> - <For each={builtIn()}>{(agent) => <AgentRow agent={agent} busy={busy()} />}</For> - </Card> - </div> + <For each={builtInGroups()}> + {(group) => ( + <SpecialistGroup + title={group.title} + description={group.description} + count={group.agents.length} + id={`specialists-${group.id}-heading`} + > + <For each={group.agents}> + {(agent) => ( + <AgentRow + agent={agent} + delegated={delegated(agent.name)} + onDelegation={(enabled) => toggleDelegation(agent.name, enabled)} + busy={busy()} + saving={Boolean(delegationPending()[agent.name])} + /> + )} + </For> + </SpecialistGroup> + )} + </For> + </div> + </Section> + </Show> </Show> </Show> </Show> - </Show> - </PanelBody> - </PanelScroll> + </PanelBody> + </PanelScroll> + </div> ) } -function AgentRow(props: { agent: Agent; onDelete?: () => void; busy: boolean }) { +const SpecialistGroup: ParentComponent<{ title: string; description: string; count: number; id: string }> = (props) => ( + <section class="specialists-group" aria-labelledby={props.id}> + <header class="specialists-group__header"> + <div> + <h4 id={props.id}>{props.title}</h4> + <p>{props.description}</p> + </div> + <span aria-label={`${props.count} specialists`}>{props.count}</span> + </header> + <div class="settings-card specialists-group__list" role="list"> + {props.children} + </div> + </section> +) + +const SpecialistIcon: Component<{ icon: IconProps["name"] }> = (props) => ( + <div class="specialists-agent__icon" aria-hidden="true"> + <Icon name={props.icon} size="small" /> + </div> +) + +function AgentRow(props: { + agent: Agent + delegated: boolean + onDelegation: (enabled: boolean) => void + onDelete?: () => void + busy: boolean + saving: boolean +}) { const label = () => LABELS[props.agent.name] ?? props.agent.name - const modeLabel = () => - props.agent.mode === "subagent" ? "subagent" : props.agent.mode === "all" ? "primary · subagent" : "primary" + const icon = () => specialistIconFor(props.agent) + const mode = () => specialistModeMeta(props.agent.mode as Mode) return ( - <Row> - <Avatar monogram={label().slice(0, 1)} tint={props.agent.color ?? undefined} /> - <div class="min-w-0 flex-1"> - <div class="flex items-center gap-2"> - <span class="text-14-medium text-text-strong truncate">{label()}</span> - <Chip>{modeLabel()}</Chip> - </div> + <div + class="settings-row specialists-agent" + role="listitem" + data-delegated={props.delegated ? "true" : "false"} + data-saving={props.saving ? "true" : undefined} + aria-busy={props.saving ? "true" : undefined} + > + <SpecialistIcon icon={icon()} /> + <div class="specialists-agent__copy"> + <strong>{label()}</strong> <Show when={props.agent.description}> - <p class="text-12-regular text-text-weak truncate mt-0.5">{props.agent.description}</p> + <p>{props.agent.description}</p> + </Show> + </div> + <div class="specialists-agent__availability"> + <span class="specialists-agent__mode" data-mode={props.agent.mode}> + {mode().label} + </span> + <Show + when={mode().canDelegate} + fallback={<span class="specialists-agent__session-only">Always available</span>} + > + <div class="specialists-agent__control"> + <Switch + data-action="specialist-delegation" + hideLabel + checked={props.delegated} + disabled={props.busy} + onChange={props.onDelegation} + > + {props.delegated ? `Stop automatic delegation to ${label()}` : `Allow automatic delegation to ${label()}`} + </Switch> + </div> </Show> </div> <Show when={props.onDelete}> - <IconButton icon="trash" variant="ghost" disabled={props.busy} aria-label="Delete" onClick={props.onDelete} /> + <IconButton + icon="trash" + variant="ghost" + disabled={props.busy} + aria-label={`Delete ${label()}`} + onClick={props.onDelete} + /> </Show> - </Row> + </div> ) } @@ -276,9 +498,12 @@ function CreateForm(props: { const [mode, setMode] = createSignal<Mode>("subagent") const valid = () => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name().trim()) && description().trim().length > 0 return ( - <div class="flex flex-col gap-4"> - <SectionLabel label="Create a custom specialist" /> - <div class="flex flex-col gap-4 p-5 border border-border-weak-base rounded-[4px] bg-surface-base/40"> + <Section + id="specialists-create-heading" + title="Create a custom specialist" + description="Describe a focused role and where it should be available." + > + <div class="specialists-panel__form"> <FormField label="Name" value={name()} @@ -291,16 +516,12 @@ function CreateForm(props: { onInput={setDescription} placeholder="When should this specialist be used?" /> - <label class="flex flex-col gap-1.5"> - <span class="text-12-medium text-text-strong">Mode</span> - <select - value={mode()} - class="h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong outline-none focus:border-border-strong-base" - onInput={(e) => setMode(e.currentTarget.value as Mode)} - > - <option value="subagent">Subagent (invoked by other agents)</option> - <option value="primary">Primary (user-selectable)</option> - <option value="all">Both</option> + <label class="specialists-panel__field"> + <span>Availability</span> + <select value={mode()} class="settings-field" onInput={(e) => setMode(e.currentTarget.value as Mode)}> + <option value="subagent">Delegated by other agents</option> + <option value="primary">Session model</option> + <option value="all">Session and delegated</option> </select> </label> <FormField @@ -310,7 +531,7 @@ function CreateForm(props: { multiline placeholder="Instructions that define this specialist's behavior…" /> - <div class="flex items-center gap-2"> + <div class="specialists-panel__form-actions"> <FormButton label={props.busy ? "Creating…" : "Create specialist"} disabled={props.busy || !valid()} @@ -319,13 +540,122 @@ function CreateForm(props: { <FormButton label="Cancel" variant="ghost" onClick={props.onCancel} disabled={props.busy} /> </div> </div> - </div> + </Section> ) } +export function matchesModeFilter(mode: Mode, filter: ModeFilter) { + if (filter === "all") return true + if (filter === "primary") return mode === "primary" || mode === "all" + return mode === "subagent" || mode === "all" +} + +export function specialistModeMeta(mode: Mode): { + label: string + canDelegate: boolean +} { + if (mode === "primary") return { label: "Session", canDelegate: false } + if (mode === "all") return { label: "Session + delegated", canDelegate: true } + return { label: "Delegated", canDelegate: true } +} + +export function specialistIconFor(agent: Pick<Agent, "name" | "description">): IconProps["name"] { + const known = ICONS[agent.name as keyof typeof ICONS] + if (known) return known + + const signature = `${agent.name} ${agent.description ?? ""}`.toLowerCase() + if (/bio|protein|gene|cell|medical/.test(signature)) return "flask" + if (/physics|quantum|math/.test(signature)) return "atom" + if (/write|document|paper|literature|citation/.test(signature)) return "book-open" + if (/code|software|repo|develop|debug/.test(signature)) return "code" + if (/review|critique|verify|audit/.test(signature)) return "shield-alert" + if (/data|model|machine learning|compute/.test(signature)) return "cpu" + + return "task" +} + function byName(a: Agent, b: Agent) { return a.name.localeCompare(b.name) } function message(err: unknown) { return err instanceof Error ? err.message : String(err) } + +type Action = "allow" | "ask" | "deny" + +function isAction(value: unknown): value is Action { + return value === "allow" || value === "ask" || value === "deny" +} + +export function taskAction(permission: unknown, name: string): Action { + if (isAction(permission)) return permission + if (!permission || typeof permission !== "object" || Array.isArray(permission)) return "allow" + const task = (permission as Record<string, unknown>).task + if (isAction(task)) return task + if (!task || typeof task !== "object" || Array.isArray(task)) return "allow" + const rules = task as Record<string, unknown> + return ( + Object.entries(rules).reduce<Action | undefined>( + (current, [pattern, action]) => (matches(name, pattern) && isAction(action) ? action : current), + undefined, + ) ?? "allow" + ) +} + +export function taskPermissionChange(permission: unknown, name: string, enabled: boolean) { + const base = isAction(permission) + ? { "*": permission } + : permission && typeof permission === "object" && !Array.isArray(permission) + ? permission + : {} + const existing = (base as Record<string, unknown>).task + const rules = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record<string, Action>) + : isAction(existing) + ? { "*": existing } + : {} + const clean = Object.fromEntries(Object.entries(rules).filter(([pattern]) => pattern !== name)) + const task = { ...clean, [name]: enabled ? ("allow" as const) : ("deny" as const) } + return { + optimistic: { ...(base as Record<string, unknown>), task }, + patch: { task }, + } +} + +export function restoreExactTaskPermission(current: unknown, before: unknown, name: string) { + const base = isAction(current) + ? { "*": current } + : current && typeof current === "object" && !Array.isArray(current) + ? current + : {} + const currentTask = (base as Record<string, unknown>).task + const rules: Record<string, Action> = + currentTask && typeof currentTask === "object" && !Array.isArray(currentTask) + ? { ...(currentTask as Record<string, Action>) } + : isAction(currentTask) + ? { "*": currentTask } + : {} + + const previousTask = + before && typeof before === "object" && !Array.isArray(before) + ? (before as Record<string, unknown>).task + : undefined + const previousRules = + previousTask && typeof previousTask === "object" && !Array.isArray(previousTask) + ? (previousTask as Record<string, unknown>) + : undefined + const previousExact = previousRules?.[name] + + if (isAction(previousExact)) rules[name] = previousExact + else delete rules[name] + return { ...(base as Record<string, unknown>), task: rules } +} + +function matches(value: string, pattern: string) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + return new RegExp(`^${escaped}$`, "s").test(value) +} diff --git a/frontend/workspace/src/components/settings/Storage.tsx b/frontend/workspace/src/components/settings/Storage.tsx index 2364f0f4..2311af7e 100644 --- a/frontend/workspace/src/components/settings/Storage.tsx +++ b/frontend/workspace/src/components/settings/Storage.tsx @@ -1,16 +1,17 @@ -// Storage — real on-disk footprint of the OpenScience data directory and a -// supported "change location" move. +// Storage — real on-disk footprint of the OpenScience data directory. // Backed by /settings/storage (routes/settings/storage.ts). -import { type Component, type JSX, For, Show, createMemo, createSignal, onMount } from "solid-js" -import { Button } from "@synsci/ui/button" +import { type Component, For, Show, createMemo, createSignal, onMount } from "solid-js" +import { Icon } from "@synsci/ui/icon" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" -import { FONT_CODE, FONT_SANS } from "@/styles/tokens" import { settingsApi } from "./api" +import { PanelBody, PanelHeader, PanelScroll, Section } from "./_shared" +import "./preference-panels.css" type Entry = { name: string; path: string; bytes: number; kind: "dir" | "file" } type Usage = { data_dir: string + managed: boolean config_dir: string cache_dir: string state_dir: string @@ -40,48 +41,56 @@ export const Storage: Component = () => { const [usage, setUsage] = createSignal<Usage>() const [error, setError] = createSignal<string>() - const [status, setStatus] = createSignal<string>() + const [loading, setLoading] = createSignal(true) const [busy, setBusy] = createSignal(false) const [editing, setEditing] = createSignal(false) const [target, setTarget] = createSignal("") + const [status, setStatus] = createSignal<string>() const load = async () => { + setLoading(true) setError(undefined) try { setUsage(await settingsApi<Usage>(base(), fetchFn(), "/settings/storage")) } catch (err) { setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) } } onMount(() => void load()) const chooseLocation = async () => { if (busy()) return + setEditing(true) setError(undefined) setStatus(undefined) - setEditing(true) if (!platform.openDirectoryPickerDialog) return - const picked = await platform.openDirectoryPickerDialog({ title: "Choose a new data location" }).catch(() => null) - const path = Array.isArray(picked) ? picked[0] : picked - if (path) setTarget(path) + const picked = await platform + .openDirectoryPickerDialog({ title: "Choose a new OpenScience data location" }) + .catch(() => null) + const selected = Array.isArray(picked) ? picked[0] : picked + if (selected) setTarget(selected) } const relocate = async () => { - const path = target().trim() - if (busy() || !path) return + const next = target().trim() + if (!next || busy()) return setBusy(true) setError(undefined) setStatus(undefined) try { - const res = await settingsApi<{ ok: boolean; target: string; restart_required: boolean }>( + const result = await settingsApi<{ ok: true; target: string; files: number; bytes: number; warning?: string }>( base(), fetchFn(), "/settings/storage/location", - { method: "POST", body: JSON.stringify({ path }) }, + { method: "POST", body: JSON.stringify({ path: next }) }, ) setEditing(false) setTarget("") - setStatus(`Data copied to ${res.target}. Restart OpenScience to use the new location.`) + setStatus( + `Moved ${fmt(result.bytes)} across ${result.files} files. Every running OpenScience server now uses ${result.target}.${result.warning ? ` ${result.warning}` : ""}`, + ) await load() } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -96,8 +105,17 @@ export const Storage: Component = () => { setError(undefined) setStatus(undefined) try { - await settingsApi(base(), fetchFn(), "/settings/storage/location", { method: "DELETE" }) - setStatus("Custom location cleared. Restart to return to the default directory.") + const result = await settingsApi<{ ok: true; target: string; backup?: string; warning?: string }>( + base(), + fetchFn(), + "/settings/storage/location", + { method: "DELETE" }, + ) + setStatus( + (result.backup + ? `Returned to ${result.target}. The previous default directory is preserved at ${result.backup}.` + : `Returned to ${result.target}.`) + (result.warning ? ` ${result.warning}` : ""), + ) await load() } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -106,200 +124,189 @@ export const Storage: Component = () => { } } - const cancelLocation = () => { - if (busy()) return - setEditing(false) - setTarget("") - setError(undefined) - } - const maxBytes = createMemo(() => Math.max(1, ...(usage()?.entries.map((e) => e.bytes) ?? [1]))) return ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> - <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <h2 class="text-16-medium text-text-strong">Storage</h2> - <p class="text-13-regular text-text-weak"> - Where OpenScience keeps data on disk, and how much space it uses. - </p> - </div> - </div> - - <div class="settings-page-body"> - <Show when={error()}> - <div role="alert" style={bannerStyle("var(--color-error)", "var(--color-error-muted)")}> - {error()} - </div> - </Show> - <Show when={status()}> - <div aria-live="polite" style={bannerStyle("var(--color-success)", "var(--color-success-muted)")}> - {status()} - </div> - </Show> + <PanelScroll> + <div class="settings-preferences-panel settings-preferences-panel--storage"> + <PanelHeader title="Storage" description="Manage local data and review its disk usage." /> + <PanelBody> + <Show when={error()}> + <div class="settings-alert whitespace-pre-wrap" data-tone="critical" role="alert"> + <span>{error()}</span> + <button type="button" class="settings-inline-action" disabled={loading()} onClick={() => void load()}> + Retry + </button> + </div> + </Show> + <Show when={status()}> + <div class="settings-alert" data-tone="success" aria-live="polite"> + <span class="settings-preference-icon" data-tone="success" aria-hidden="true"> + <Icon name="check" size="small" /> + </span> + <span class="min-w-0 flex-1">{status()}</span> + </div> + </Show> - {/* Data location */} - <div class="flex flex-col gap-3"> - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide">Data location</h3> - <p class="text-12-regular text-text-weak"> - The directory holding sessions, credentials, skills, and logs. The default is ~/.openscience. - </p> - </div> - <div style={{ border: "1px solid var(--color-border)", "border-radius": "4px", padding: "16px 18px" }}> - <div class="flex flex-wrap items-center justify-between gap-3"> - <div class="flex flex-col gap-1 min-w-0"> - <span - class="text-13-regular text-text-strong truncate" - style={{ "font-family": FONT_CODE }} - title={usage()?.data_dir} - > - {usage()?.data_dir ?? "…"} + <Section title="Data location" description="Sessions, credentials, skills, and logs live here."> + <div class="settings-card settings-preferences-card"> + <div class="settings-storage-location"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name="folder" size="small" /> </span> - <span class="text-12-regular text-text-weak"> - {fmt(usage()?.total_bytes ?? 0)} total - <Show when={usage()?.pointer}> · custom location</Show> - </span> - </div> - <div class="flex gap-2 flex-shrink-0"> - <Show when={usage()?.pointer}> - <Button size="small" variant="secondary" disabled={busy()} onClick={() => void resetLocation()}> - Reset location - </Button> - </Show> - <Show when={!editing()}> - <Button size="small" variant="secondary" disabled={busy()} onClick={() => void chooseLocation()}> - Change location - </Button> - </Show> - </div> - </div> - <Show when={editing()}> - <div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border-weak-base"> - <label for="storage-location-input" class="text-12-medium text-text-strong"> - New data directory - </label> - <input - id="storage-location-input" - aria-label="New data directory" - value={target()} - onInput={(event) => setTarget(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key !== "Enter") return - event.preventDefault() - void relocate() - }} - placeholder="/Users/you/OpenScience-data" - spellcheck={false} - autofocus - class="text-12-mono text-text-strong bg-transparent outline-none" - style={{ - width: "100%", - "box-sizing": "border-box", - padding: "8px 10px", - border: "1px solid var(--color-border)", - "border-radius": "4px", - "font-family": FONT_CODE, - }} - /> - <p class="text-12-regular text-text-weak max-w-[62ch]"> - OpenScience copies the current data into an empty folder and switches to it after restart. The - original stays in place as a safety copy. - </p> - <div class="flex flex-wrap items-center justify-end gap-2 mt-1"> - <Show when={platform.openDirectoryPickerDialog}> - <Button size="small" variant="secondary" disabled={busy()} onClick={() => void chooseLocation()}> - Browse… - </Button> + <div class="settings-row-copy"> + <div class="flex min-w-0 flex-wrap items-center gap-2"> + <strong class="settings-storage-path min-w-0 truncate font-mono" title={usage()?.data_dir}> + {usage()?.data_dir ?? "…"} + </strong> + <Show when={usage()?.pointer}> + <span class="settings-preference-status">Custom</span> + </Show> + </div> + <span class="settings-storage-size"> + {usage() ? `${fmt(usage()!.total_bytes)} total` : "Loading storage…"} + </span> + </div> + <div class="settings-storage-location__actions flex shrink-0 items-center gap-1"> + <Show when={usage()?.pointer}> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + disabled={busy() || !usage()?.managed} + onClick={() => void resetLocation()} + > + Use default + </button> + </Show> + <Show when={!editing()}> + <button + type="button" + class="settings-preference-action" + disabled={busy() || !usage()?.managed} + onClick={() => void chooseLocation()} + > + Change location… + </button> </Show> - <Button size="small" variant="secondary" disabled={busy()} onClick={cancelLocation}> - Cancel - </Button> - <Button - size="small" - variant="primary" - disabled={busy() || !target().trim()} - onClick={() => void relocate()} - > - {busy() ? "Copying…" : "Copy data"} - </Button> </div> </div> - </Show> - </div> - </div> + <Show when={editing()}> + <div class="settings-inline-editor"> + <label for="storage-location-input" class="text-12-medium text-text-strong"> + New data directory + </label> + <input + id="storage-location-input" + class="settings-field min-w-0 flex-1 basis-[240px] font-mono" + value={target()} + placeholder="/Users/you/OpenScience-data" + spellcheck={false} + autofocus + onInput={(event) => setTarget(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return + event.preventDefault() + void relocate() + }} + /> + <p class="max-w-[68ch] text-12-regular text-text-weak"> + OpenScience verifies files and SQLite data, pauses active writes, and switches every running server + together. The current directory remains untouched as a safety copy. + </p> + <div class="settings-inline-editor__actions"> + <Show when={platform.openDirectoryPickerDialog}> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + disabled={busy()} + onClick={() => void chooseLocation()} + > + Browse… + </button> + </Show> + <button + type="button" + class="settings-preference-action" + data-variant="quiet" + disabled={busy()} + onClick={() => { + setEditing(false) + setTarget("") + }} + > + Cancel + </button> + <button + type="button" + class="settings-preference-action" + data-variant="primary" + disabled={busy() || !target().trim()} + onClick={() => void relocate()} + > + {busy() ? "Moving…" : "Move data"} + </button> + </div> + </div> + </Show> + <Show when={usage() && !usage()!.managed}> + <div class="settings-alert m-3 mt-0" data-tone="neutral"> + <Icon name="alert-circle" size="small" class="shrink-0 text-icon-weak-base" /> + <span>OPENSCIENCE_DATA_DIR owns this process, so change that environment setting to move it.</span> + </div> + </Show> + </div> + </Section> - {/* Disk usage */} - <div class="flex flex-col gap-3"> - <div class="flex flex-col gap-1"> - <h3 class="text-13-medium text-text-weak tracking-wide">Disk usage</h3> - <p class="text-12-regular text-text-weak">Top-level entries inside the data directory, largest first.</p> - </div> - <Show - when={usage() && usage()!.entries.length > 0} - fallback={ - <div - class="text-12-regular text-text-weak" - style={{ - border: "1px dashed var(--color-border-strong)", - "border-radius": "4px", - padding: "14px 16px", - }} - > - {usage() ? "Nothing stored yet." : "Loading…"} - </div> - } - > - <div style={{ border: "1px solid var(--color-border)", "border-radius": "4px", overflow: "hidden" }}> - <For each={usage()!.entries}> - {(entry) => ( - <div class="flex flex-col gap-1.5 px-4 py-3 border-b border-border-weak-base last:border-none"> - <div class="flex items-center justify-between gap-3"> - <span class="text-13-regular text-text-strong truncate" style={{ "font-family": FONT_CODE }}> + <Section title="Disk usage" description="Top-level entries inside the data directory, largest first."> + <Show + when={usage() && usage()!.entries.length > 0} + fallback={ + <div class="settings-card settings-preferences-card"> + <div class="settings-row settings-preference-row text-12-regular text-text-weak"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name="archive" size="small" /> + </span> + {usage() ? "Nothing stored yet." : "Loading storage…"} + </div> + </div> + } + > + <div class="settings-card settings-preferences-card"> + <For each={usage()!.entries}> + {(entry) => ( + <div class="settings-row settings-preference-row settings-storage-usage-row"> + <span class="settings-preference-icon" aria-hidden="true"> + <Icon name={entry.kind === "dir" ? "folder" : "file"} size="small" /> + </span> + <code class="min-w-0 truncate text-13-regular text-text-strong"> {entry.name} {entry.kind === "dir" ? "/" : ""} + </code> + <span class="settings-storage-metric text-12-regular text-text-weak flex-shrink-0"> + {fmt(entry.bytes)} </span> - <span class="text-12-regular text-text-weak flex-shrink-0">{fmt(entry.bytes)}</span> - </div> - <div - style={{ - height: "4px", - "border-radius": "999px", - background: "var(--color-border-weak-base, rgba(255,255,255,0.08))", - overflow: "hidden", - }} - > <div - style={{ - height: "100%", - width: `${Math.max(2, (entry.bytes / maxBytes()) * 100)}%`, - background: "var(--color-text-interactive-base, var(--color-text))", - "border-radius": "999px", - }} - /> + class="settings-storage-meter" + role="progressbar" + aria-label={`${entry.name} relative disk usage`} + aria-valuemin="0" + aria-valuemax={maxBytes()} + aria-valuenow={entry.bytes} + > + <span style={{ width: `${Math.max(2, (entry.bytes / maxBytes()) * 100)}%` }} /> + </div> </div> - </div> - )} - </For> - </div> - </Show> - </div> + )} + </For> + </div> + </Show> + </Section> + </PanelBody> </div> - </div> + </PanelScroll> ) } export default Storage - -function bannerStyle(color: string, border: string): JSX.CSSProperties { - return { - "font-family": FONT_SANS, - "font-size": "12px", - "line-height": 1.5, - color, - border: `1px solid ${border}`, - "border-radius": "4px", - padding: "10px 12px", - "white-space": "pre-wrap", - } -} diff --git a/frontend/workspace/src/components/settings/_shared.tsx b/frontend/workspace/src/components/settings/_shared.tsx index 191406ac..18a014e5 100644 --- a/frontend/workspace/src/components/settings/_shared.tsx +++ b/frontend/workspace/src/components/settings/_shared.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal, type JSX, type ParentComponent, type Component } from "solid-js" +import { For, Show, createSignal, createUniqueId, type JSX, type ParentComponent, type Component } from "solid-js" import { Icon } from "@synsci/ui/icon" import type { IconProps } from "@synsci/ui/icon" import { DropdownMenu } from "@synsci/ui/dropdown-menu" @@ -20,17 +20,17 @@ function useDialogMount() { // Shared visual language for the OpenScience settings panels. Matches the // reference (rounded cards, muted subheaders, filter/search/add toolbar) while -// inheriting the app's Computer Modern font — no token/font edits. Panels stay -// one-file-each; this module is pure presentational infra they compose. +// inheriting the workspace type stack and theme tokens. Panels stay one-file- +// each; this module is pure presentational infrastructure they compose. export const PanelScroll: ParentComponent = (props) => ( - <div class="flex flex-col h-full overflow-y-auto no-scrollbar">{props.children}</div> + <div class="flex min-h-0 min-w-0 flex-col h-full overflow-y-auto no-scrollbar">{props.children}</div> ) export const PanelHeader: Component<{ title: string; description: string; toolbar?: JSX.Element }> = (props) => ( <div class="settings-page-header"> - <div class="settings-page-header__inner"> - <div class="flex flex-col gap-1"> + <div class="settings-page-header__inner min-w-0"> + <div class="flex min-w-0 flex-col gap-1"> <h2 class="text-16-medium text-text-strong">{props.title}</h2> <p class="text-13-regular text-text-weak">{props.description}</p> </div> @@ -39,37 +39,74 @@ export const PanelHeader: Component<{ title: string; description: string; toolba </div> ) -export const PanelBody: ParentComponent = (props) => <div class="settings-page-body">{props.children}</div> +export const PanelBody: ParentComponent = (props) => <div class="settings-page-body min-w-0">{props.children}</div> + +export const Section: ParentComponent<{ + title: string + description?: JSX.Element + count?: number + action?: JSX.Element + id?: string +}> = (props) => { + const generated = `settings-${createUniqueId()}` + const id = () => props.id ?? generated + return ( + <section class="settings-section" aria-labelledby={id()}> + <div class="settings-section-heading"> + <div> + <h3 id={id()}>{props.title}</h3> + <Show when={props.description}> + <p>{props.description}</p> + </Show> + </div> + <Show + when={props.action} + fallback={ + <Show when={props.count !== undefined}> + <span>{props.count}</span> + </Show> + } + > + {props.action} + </Show> + </div> + {props.children} + </section> + ) +} + +export const RowCopy: Component<{ title: string; description?: string; mono?: boolean }> = (props) => ( + <div class="settings-list-copy"> + <strong classList={{ "font-mono": props.mono }}>{props.title}</strong> + <Show when={props.description}> + <span class="whitespace-normal text-ellipsis">{props.description}</span> + </Show> + </div> +) // Muted sentence-case subheader with a trailing count. export const SectionLabel: Component<{ label: string; count?: number }> = (props) => ( - <div class="flex items-center gap-2 px-0.5"> - <span class="settings-section-label">{props.label}</span> + <div class="settings-section-heading settings-section-heading--compact"> + <h3 class="settings-section-label min-w-0 break-words">{props.label}</h3> <Show when={props.count !== undefined}> - <span class="text-10-regular text-text-weaker">{props.count}</span> + <span>{props.count}</span> </Show> </div> ) // Rounded card wrapping a stack of rows (dividers between children handled by // Row's border-b). Use for grouped lists. -export const Card: ParentComponent = (props) => ( - <div class="border border-border-weak-base rounded-[10px] overflow-hidden bg-surface-base/40">{props.children}</div> -) +export const Card: ParentComponent = (props) => <div class="settings-card min-w-0 w-full">{props.children}</div> export const Row: ParentComponent<{ onClick?: () => void }> = (props) => ( - <div - class="flex flex-wrap items-center gap-3 px-4 py-3.5 border-b border-border-weak-base last:border-none" - classList={{ "cursor-pointer hover:bg-surface-raised-base/40": !!props.onClick }} - onClick={props.onClick} - > + <div class="settings-row min-w-0" data-interactive={props.onClick ? "true" : undefined} onClick={props.onClick}> {props.children} </div> ) export const EmptyState: Component<{ icon: IconProps["name"]; title: string; hint?: string }> = (props) => ( - <div class="flex flex-col items-center gap-3 text-center py-14"> - <div class="flex items-center justify-center size-11 rounded-[4px] border border-border-weak-base bg-surface-base/40 text-icon-weak-base"> + <div class="settings-empty-state min-w-0"> + <div class="settings-empty-state__icon"> <Icon name={props.icon} size="normal" /> </div> <span class="text-14-medium text-text-strong">{props.title}</span> @@ -86,11 +123,10 @@ export const EmptyState: Component<{ icon: IconProps["name"]; title: string; hin // washes the tile background; omit it for a neutral tile. export const Avatar: Component<{ tint?: string; icon?: IconProps["name"]; monogram?: string }> = (props) => ( <div - class="flex items-center justify-center size-8 rounded-[5px] flex-shrink-0 text-13-medium leading-none uppercase" + class="settings-avatar" + data-tinted={props.tint ? "true" : undefined} style={{ - background: props.tint - ? `color-mix(in srgb, ${props.tint} 14%, transparent)` - : "var(--color-surface-raised-base)", + background: props.tint ? `color-mix(in srgb, ${props.tint} 14%, transparent)` : undefined, color: props.monogram && props.tint ? props.tint : "var(--color-icon-strong-base)", }} > @@ -101,38 +137,42 @@ export const Avatar: Component<{ tint?: string; icon?: IconProps["name"]; monogr ) // Small inline metadata badge (a specialist's mode, a connector's type). -export const Chip: ParentComponent = (props) => ( - <span class="text-11-medium text-text-weak/70 px-1.5 py-0.5 rounded-md bg-surface-raised-base/60 flex-shrink-0"> - {props.children} - </span> -) +export const Chip: ParentComponent = (props) => <span class="settings-chip">{props.children}</span> // ── Toolbar pieces ────────────────────────────────────────────────────────── -const controlBase = - "flex items-center gap-2 h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-medium transition-colors" +const controlBase = "settings-control" -export const SearchInput: Component<{ value: string; onInput: (v: string) => void; placeholder?: string }> = ( - props, -) => ( - <label class={`${controlBase} flex-1 min-w-[140px] focus-within:border-border-strong-base cursor-text`}> +export const SearchInput: Component<{ + value: string + onInput: (v: string) => void + placeholder?: string + ariaLabel?: string +}> = (props) => ( + <div class={`${controlBase} settings-control--search max-w-full`}> <Icon name="magnifying-glass" size="small" class="text-icon-weak-base flex-shrink-0" /> <input type="text" + aria-label={props.ariaLabel ?? props.placeholder ?? "Search"} value={props.value} placeholder={props.placeholder ?? "Search"} spellcheck={false} autocapitalize="off" autocomplete="off" - class="flex-1 bg-transparent outline-none text-text-strong placeholder:text-text-weak/60" + class="min-w-0 flex-1 bg-transparent outline-none text-text-strong placeholder:text-text-weak/60" onInput={(e) => props.onInput(e.currentTarget.value)} /> <Show when={props.value}> - <button type="button" class="text-icon-weak-base hover:text-text-strong" onClick={() => props.onInput("")}> + <button + type="button" + class="shrink-0 text-icon-weak-base hover:text-text-strong" + aria-label="Clear search" + onClick={() => props.onInput("")} + > <Icon name="circle-x" size="small" /> </button> </Show> - </label> + </div> ) export interface FilterOption { @@ -141,22 +181,26 @@ export interface FilterOption { count?: number } -export const FilterMenu: Component<{ options: FilterOption[]; value: string; onSelect: (id: string) => void }> = ( - props, -) => { +export const FilterMenu: Component<{ + options: FilterOption[] + value: string + onSelect: (id: string) => void + ariaLabel?: string +}> = (props) => { const active = () => props.options.find((o) => o.id === props.value) ?? props.options[0] const dialog = useDialogMount() return ( <DropdownMenu> <DropdownMenu.Trigger ref={dialog.anchor} - class={`${controlBase} text-text-strong hover:bg-surface-raised-base/60 data-[expanded]:bg-surface-raised-base-active flex-shrink-0`} + aria-label={props.ariaLabel} + class={`${controlBase} settings-control--menu max-w-full`} > - <span class="truncate max-w-[160px]"> + <span class="min-w-0 truncate max-w-[160px]"> {active()?.label} <Show when={active()?.count !== undefined}> ({active()?.count})</Show> </span> - <Icon name="chevron-down" size="small" class="text-icon-weak-base" /> + <Icon name="chevron-down" size="small" class="shrink-0 text-icon-weak-base" /> </DropdownMenu.Trigger> <DropdownMenu.Portal mount={dialog.mount()}> <DropdownMenu.Content class="mt-1 min-w-[180px]"> @@ -190,11 +234,11 @@ export const AddMenu: Component<{ label: string; items: AddItem[] }> = (props) = <DropdownMenu.Trigger ref={dialog.anchor} aria-label={props.label} - class={`${controlBase} text-text-strong bg-surface-raised-base-active hover:bg-surface-raised-base-active/80 data-[expanded]:bg-surface-raised-base-active flex-shrink-0`} + class={`${controlBase} settings-control--primary max-w-full`} > - <Icon name="plus" size="small" /> - <span class="truncate">{props.label}</span> - <Icon name="chevron-down" size="small" class="text-icon-weak-base" /> + <Icon name="plus" size="small" class="shrink-0" /> + <span class="min-w-0 truncate">{props.label}</span> + <Icon name="chevron-down" size="small" class="shrink-0 text-icon-weak-base" /> </DropdownMenu.Trigger> <DropdownMenu.Portal mount={dialog.mount()}> <DropdownMenu.Content class="mt-1 min-w-[240px]"> @@ -219,7 +263,7 @@ export const AddMenu: Component<{ label: string; items: AddItem[] }> = (props) = ) } -export const Toolbar: ParentComponent = (props) => <div class="flex items-center gap-2 flex-wrap">{props.children}</div> +export const Toolbar: ParentComponent = (props) => <div class="settings-toolbar min-w-0">{props.children}</div> // A small labelled text/textarea field used by the inline creation forms. export const FormField: Component<{ @@ -231,7 +275,7 @@ export const FormField: Component<{ disabled?: boolean mono?: boolean }> = (props) => ( - <label class="flex flex-col gap-1.5"> + <label class="flex min-w-0 flex-col gap-1.5"> <span class="text-12-medium text-text-strong">{props.label}</span> <Show when={props.multiline} @@ -241,7 +285,7 @@ export const FormField: Component<{ value={props.value} disabled={props.disabled} placeholder={props.placeholder} - class="h-9 px-3 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong outline-none focus:border-border-strong-base placeholder:text-text-weak/60" + class="settings-field" onInput={(e) => props.onInput(e.currentTarget.value)} /> } @@ -251,7 +295,7 @@ export const FormField: Component<{ disabled={props.disabled} placeholder={props.placeholder} rows={5} - class="px-3 py-2 rounded-xs border border-border-weak-base bg-surface-base text-13-regular text-text-strong outline-none focus:border-border-strong-base resize-y min-h-[96px] placeholder:text-text-weak/60" + class="settings-field settings-field--multiline" classList={{ "font-mono": props.mono }} onInput={(e) => props.onInput(e.currentTarget.value)} /> @@ -269,14 +313,8 @@ export const FormButton: Component<{ type="button" disabled={props.disabled} onClick={props.onClick} - class="h-9 px-4 rounded-xs text-13-medium transition-colors disabled:opacity-50" - classList={{ - "bg-surface-raised-base-active text-text-strong hover:bg-surface-raised-base-active/80": - (props.variant ?? "primary") === "primary", - "border border-border-weak-base text-text-weak hover:text-text-strong hover:bg-surface-raised-base/60": - props.variant === "ghost", - "text-text-on-critical-base hover:bg-surface-critical-weak": props.variant === "danger", - }} + class="settings-button max-w-full" + data-variant={props.variant ?? "primary"} > {props.label} </button> diff --git a/frontend/workspace/src/components/settings/connector-form.ts b/frontend/workspace/src/components/settings/connector-form.ts new file mode 100644 index 00000000..b7f3bb0c --- /dev/null +++ b/frontend/workspace/src/components/settings/connector-form.ts @@ -0,0 +1,180 @@ +import type { Config } from "@synsci/sdk/v2/client" +import { formatConnectorCommand, parseConnectorCommand } from "./connector-command" + +type McpConfig = NonNullable<Config["mcp"]>[string] +export type McpType = "local" | "remote" +export type OAuthMode = "off" | "auto" | "client" +export type ConfiguredMcp = Extract<McpConfig, { type: McpType }> +export type ConnectorIdentityIcon = "cloud" | "console" | "discord" | "folder" | "github" | "server" + +export interface ConnectorIdentity { + icon: ConnectorIdentityIcon + label: string +} + +const MASK = "••••••••" + +export interface ConnectorFormState { + name: string + type: McpType + command: string + url: string + env: string + headers: string + oauth: OAuthMode + clientId: string + clientSecret: string + scope: string + timeout: string + previous?: ConfiguredMcp +} + +export function blankConnectorForm(type: McpType): ConnectorFormState { + return { + name: "", + type, + command: "", + url: "", + env: "", + headers: "", + oauth: "auto", + clientId: "", + clientSecret: "", + scope: "", + timeout: "", + } +} + +/** + * Gives common connectors a recognizable identity without pretending every + * MCP server has a bespoke brand asset. Unknown entries fall back to their + * transport, so a hosted server never looks identical to a local process. + */ +export function connectorIdentity(name: string, config: ConfiguredMcp): ConnectorIdentity { + const target = config.type === "remote" ? config.url : [name, ...config.command].join(" ") + const haystack = `${name} ${target}`.toLowerCase() + + if (haystack.includes("github")) return { icon: "github", label: "GitHub" } + if (haystack.includes("discord")) return { icon: "discord", label: "Discord" } + if (/file[ -]?system|local[ -]?files|workspace[ -]?files/u.test(haystack)) { + return { icon: "folder", label: "Filesystem" } + } + if (/postgres|database|supabase|sqlite/u.test(haystack)) { + return { icon: "server", label: "Database server" } + } + if (config.type === "remote") return { icon: "cloud", label: "Hosted server" } + return { icon: "console", label: "Local process" } +} + +export function connectorFormFromConfig(name: string, config: ConfiguredMcp): ConnectorFormState { + const base = blankConnectorForm(config.type) + base.name = name + base.previous = config + base.timeout = config.timeout ? String(config.timeout) : "" + if (config.type === "local") { + base.command = formatConnectorCommand(config.command) + base.env = config.environment ? JSON.stringify(maskRecord(config.environment), null, 2) : "" + return base + } + base.url = config.url + base.headers = config.headers ? JSON.stringify(maskRecord(config.headers), null, 2) : "" + if (config.oauth === false) base.oauth = "off" + else if (config.oauth && "clientId" in config.oauth && config.oauth.clientId) { + base.oauth = "client" + base.clientId = config.oauth.clientId + base.clientSecret = config.oauth.clientSecret ? MASK : "" + base.scope = config.oauth.scope ?? "" + } else base.oauth = "auto" + return base +} + +function maskRecord(value: Record<string, string>) { + return Object.fromEntries(Object.keys(value).map((key) => [key, MASK])) +} + +export function maskConnectorConfig(value: ConfiguredMcp): ConfiguredMcp { + if (value.type === "local") { + return { + ...value, + environment: value.environment ? maskRecord(value.environment) : undefined, + } + } + return { + ...value, + headers: value.headers ? maskRecord(value.headers) : undefined, + oauth: + value.oauth && typeof value.oauth === "object" + ? { + ...value.oauth, + clientSecret: value.oauth.clientSecret ? MASK : undefined, + } + : value.oauth, + } +} + +function restoreRecord(value: Record<string, string> | undefined, previous: Record<string, string> | undefined) { + if (!value) return undefined + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => { + if (entry !== MASK) return [key, entry] + const stored = previous?.[key] + if (stored === undefined) throw new Error(`Replace the masked value for ${key} before saving`) + return [key, stored] + }), + ) +} + +function parseRecord(text: string, label: string) { + const trimmed = text.trim() + if (!trimmed) return undefined + const parsed = JSON.parse(trimmed) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must be a JSON object`) + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") throw new Error(`${label}.${key} must be a string`) + } + return parsed as Record<string, string> +} + +export function buildConnectorConfig(state: ConnectorFormState): ConfiguredMcp { + const timeout = state.timeout.trim() ? Number(state.timeout) : undefined + if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0)) { + throw new Error("Timeout must be a positive whole number of milliseconds") + } + const enabled = state.previous?.enabled + if (state.type === "local") { + const command = parseConnectorCommand(state.command) + if (command.length === 0) throw new Error("Command is required") + const previous = state.previous?.type === "local" ? state.previous : undefined + const environment = restoreRecord(parseRecord(state.env, "Environment"), previous?.environment) + return { + type: "local", + command, + ...(environment ? { environment } : {}), + ...(enabled === false ? { enabled } : {}), + ...(timeout ? { timeout } : {}), + } + } + if (!URL.canParse(state.url.trim())) throw new Error("Remote URL is invalid") + const previous = state.previous?.type === "remote" ? state.previous : undefined + const headers = restoreRecord(parseRecord(state.headers, "Headers"), previous?.headers) + const oauth = typeof previous?.oauth === "object" ? previous.oauth : undefined + const secret = state.clientSecret.trim() === MASK ? oauth?.clientSecret : state.clientSecret.trim() + return { + type: "remote", + url: state.url.trim(), + ...(headers ? { headers } : {}), + ...(enabled === false ? { enabled } : {}), + ...(timeout ? { timeout } : {}), + ...(state.oauth === "off" + ? { oauth: false } + : state.oauth === "client" + ? { + oauth: { + clientId: state.clientId.trim(), + ...(secret ? { clientSecret: secret } : {}), + ...(state.scope.trim() ? { scope: state.scope.trim() } : {}), + }, + } + : { oauth: {} }), + } +} diff --git a/frontend/workspace/src/components/settings/connectors-style.test.ts b/frontend/workspace/src/components/settings/connectors-style.test.ts new file mode 100644 index 00000000..c3b598c7 --- /dev/null +++ b/frontend/workspace/src/components/settings/connectors-style.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" + +const css = await Bun.file(new URL("./connectors.css", import.meta.url)).text() +const source = await Bun.file(new URL("./Connectors.tsx", import.meta.url)).text() + +describe("Connectors settings visual contract", () => { + test("uses the compact neutral workspace control language", () => { + expect(css).toContain("--connectors-control-height: 32px") + expect(css).toContain("background: var(--settings-primary)") + expect(css).toContain("background: var(--settings-toggle-active)") + expect(css).toContain("border: 1px solid var(--border-weak-base)") + expect(css).not.toMatch(/#(?:007aff|0a84ff|0066d6|409cff)/i) + }) + + test("keeps identity, status, and row actions in stable aligned columns", () => { + expect(css).toContain("--connectors-row-columns: 32px minmax(180px, 1fr) minmax(92px, auto) auto") + expect(css).toContain("grid-template-columns: var(--connectors-row-columns)") + expect(source).toContain('class="connectors-status" data-tone={dot(s())}') + expect(source).toContain("const identity = connectorIdentity(name, config)") + }) + + test("preserves every connector operation with advanced actions disclosed in details", () => { + expect(source).toContain("void authenticate(name)") + expect(source).toContain("void disconnectAuth(name)") + expect(source).toContain("editConnector(name, config)") + expect(source).toContain("void remove(name)") + expect(source).toContain("void toggle(name, v)") + expect(source).toContain("<ConnectorInspection detail={detail()} />") + }) + + test("expands hit areas for touch without enlarging desktop controls", () => { + expect(css).toContain("@media (pointer: coarse)") + expect(css).toContain("min-width: 44px") + expect(css).toContain("min-height: 44px") + expect(css).toContain("min-width: 32px") + expect(css).toContain("min-height: 32px") + }) +}) diff --git a/frontend/workspace/src/components/settings/connectors.css b/frontend/workspace/src/components/settings/connectors.css new file mode 100644 index 00000000..1d3a7be7 --- /dev/null +++ b/frontend/workspace/src/components/settings/connectors.css @@ -0,0 +1,564 @@ +/* Connectors follows the workspace's compact neutral language. Semantic color + is reserved for live status; transport, controls, and form chrome stay + monochrome so this page reads like the rest of the workbench. */ +.connectors-panel { + --connectors-control-height: 32px; + --connectors-radius: var(--radius-sm); + --connectors-row-columns: 32px minmax(180px, 1fr) minmax(92px, auto) auto; + + min-width: 0; +} + +.connectors-panel .settings-page-header { + gap: 12px; +} + +.connectors-panel .settings-toolbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto; + gap: 8px; +} + +.connectors-panel .settings-control, +.connectors-panel .settings-button, +.connectors-panel .settings-inline-action, +.connectors-panel .settings-field { + min-height: var(--connectors-control-height); + height: var(--connectors-control-height); + border-radius: var(--connectors-radius); + font-size: 12px; +} + +.connectors-panel .settings-control { + padding-inline: 10px; + border: 1px solid var(--border-weak-base); + background: var(--surface-raised-base); +} + +.connectors-panel .settings-control--search:focus-within, +.connectors-panel .settings-field:focus { + border-color: var(--border-strong-base); + box-shadow: none; +} + +.connectors-panel .settings-control--primary, +.connectors-panel .settings-button[data-variant="primary"] { + border-color: transparent; + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: none; +} + +.connectors-panel .settings-control--primary:hover, +.connectors-panel .settings-button[data-variant="primary"]:hover { + background: var(--settings-primary-hover); +} + +.connectors-panel .settings-button { + padding-inline: 12px; +} + +.connectors-panel .settings-button[data-variant="ghost"] { + border-color: var(--border-weak-base); + background: transparent; +} + +.connectors-list { + display: flex; + min-width: 0; + flex-direction: column; +} + +.connectors-item { + min-width: 0; + border-top: 1px solid var(--border-weak-base); +} + +.connectors-item:last-child { + border-bottom: 1px solid var(--border-weak-base); +} + +.connectors-item[data-expanded="true"] { + background: var(--surface-raised-base); + border-radius: var(--connectors-radius); +} + +.connectors-item[data-expanded="true"] + .connectors-item { + border-top-color: transparent; +} + +.connectors-row { + min-height: 64px; + display: grid; + grid-template-columns: var(--connectors-row-columns); + align-items: center; + gap: 12px; + padding: 8px 4px; +} + +.connectors-item[data-expanded="true"] .connectors-row { + padding-inline: 12px; +} + +.connectors-identity { + width: 32px; + height: 32px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--border-weak-base); + border-radius: var(--connectors-radius); + background: var(--surface-raised-base); + color: var(--icon-strong-base); +} + +.connectors-copy { + min-width: 0; +} + +.connectors-copy__title { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.connectors-copy__title strong { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connectors-copy__title > span { + flex: 0 0 auto; + color: var(--text-weak); + font-size: 11px; + line-height: 16px; +} + +.connectors-copy > p { + margin: 1px 0 0; + overflow: hidden; + color: var(--text-weak); + font-family: var(--font-family-mono); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connectors-capability-summary { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin-top: 2px; + color: var(--text-weaker); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; +} + +.connectors-status { + min-width: 0; + display: inline-flex; + align-items: center; + justify-self: start; + gap: 6px; + color: var(--text-weak); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 16px; + white-space: nowrap; +} + +.connectors-status > span { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--icon-disabled); +} + +.connectors-status[data-tone="active"] > span { + background: var(--icon-success-base); +} + +.connectors-status[data-tone="pending"] > span { + background: var(--icon-warning-base); +} + +.connectors-status[data-tone="error"] { + color: var(--text-danger); +} + +.connectors-status[data-tone="error"] > span { + background: var(--text-danger); +} + +.connectors-row__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.connectors-panel [data-component="icon-button"], +.connectors-action, +.connectors-refresh, +.connectors-detail-action { + min-width: 32px; + min-height: 32px; + border-radius: var(--connectors-radius); + transition: + background-color 150ms ease, + color 150ms ease, + opacity 150ms ease, + transform 120ms ease; +} + +.connectors-action { + padding: 0 10px; + color: var(--text-strong); + font-size: 11px; + font-weight: var(--font-weight-medium); +} + +.connectors-action:hover, +.connectors-refresh:hover, +.connectors-detail-action:hover, +.connectors-panel [data-component="icon-button"]:hover:not(:disabled) { + background: var(--surface-raised-base-hover); +} + +.connectors-panel :is(button, [data-component="icon-button"]):active:not(:disabled) { + transform: scale(0.98); +} + +.connectors-panel [data-component="switch"] { + min-width: 40px; + min-height: 32px; + justify-content: center; +} + +.connectors-panel [data-component="switch"] [data-slot="switch-control"] { + width: 28px; + height: 16px; + border: 1px solid var(--border-weak-base); + background: var(--surface-raised-base); +} + +.connectors-panel [data-component="switch"] [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + margin: 0; + transform: translateX(-1px); +} + +.connectors-panel [data-component="switch"][data-checked] [data-slot="switch-control"], +.connectors-panel + [data-component="switch"][data-checked]:hover:not([data-disabled], [data-readonly]) + [data-slot="switch-control"] { + border-color: var(--settings-toggle-active); + background: var(--settings-toggle-active); +} + +.connectors-panel [data-component="switch"][data-checked] [data-slot="switch-thumb"] { + transform: translateX(12px); +} + +.connectors-details { + padding: 0 12px 12px 56px; +} + +.connectors-inspection { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; +} + +.connectors-inspection__loading { + color: var(--text-weak); + font-size: 12px; +} + +.connectors-inspection__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.connectors-capability { + min-width: 0; +} + +.connectors-capability header { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-height: 24px; + color: var(--text-weak); +} + +.connectors-capability h3 { + margin: 0; + color: inherit; + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 16px; +} + +.connectors-capability header > span { + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.connectors-capability ul { + display: flex; + flex-direction: column; + gap: 8px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.connectors-capability li { + min-width: 0; +} + +.connectors-capability__name { + margin: 0; + overflow: hidden; + color: var(--text-strong); + font-family: var(--font-family-mono); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connectors-capability__description, +.connectors-capability__empty { + display: -webkit-box; + margin: 1px 0 0; + overflow: hidden; + color: var(--text-weak); + font-size: 11px; + line-height: 16px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-wrap: pretty; +} + +.connectors-details__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid var(--border-weak-base); +} + +.connectors-detail-action, +.connectors-refresh { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 8px; + color: var(--text-weak); + font-size: 11px; + font-weight: var(--font-weight-medium); +} + +.connectors-detail-action--danger:hover { + color: var(--text-danger); + background: var(--surface-critical-weak); +} + +.connectors-refresh { + align-self: flex-start; + margin-top: 4px; +} + +.connectors-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 48px 16px; + text-align: center; +} + +.connectors-empty__icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-weak-base); + border-radius: var(--connectors-radius); + color: var(--icon-weak-base); +} + +.connectors-empty__copy { + max-width: 380px; +} + +.connectors-empty__copy strong, +.connectors-form__lead strong { + color: var(--text-strong); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 18px; +} + +.connectors-empty__copy p, +.connectors-form__lead p { + margin: 2px 0 0; + color: var(--text-weak); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; +} + +.connectors-empty__actions, +.connectors-form__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.connectors-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.connectors-form__lead { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-weak-base); +} + +.connectors-form__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; +} + +.connectors-form__field { + min-width: 0; +} + +.connectors-form__field[data-span="full"], +.connectors-form__hint { + grid-column: 1 / -1; +} + +.connectors-form__select { + display: flex; + flex-direction: column; + gap: 6px; +} + +.connectors-form__select > span, +.connectors-panel .connectors-form__field label > span { + color: var(--text-strong); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 16px; +} + +.connectors-panel .settings-field { + padding: 6px 10px; + border: 1px solid var(--border-weak-base); + background: var(--surface-raised-base); +} + +.connectors-panel .settings-field--multiline { + min-height: 88px; + height: auto; +} + +.connectors-form__hint { + margin: -4px 0 0; + color: var(--text-weak); + font-size: 11px; + line-height: 16px; + text-wrap: pretty; +} + +@media (max-width: 840px) { + .connectors-panel { + --connectors-row-columns: 32px minmax(0, 1fr) auto; + } + + .connectors-status { + display: none; + } +} + +@media (max-width: 680px) { + .connectors-panel .settings-toolbar, + .connectors-form__grid { + grid-template-columns: 1fr; + } + + .connectors-form__field[data-span="full"], + .connectors-form__hint { + grid-column: auto; + } + + .connectors-row { + grid-template-columns: 32px minmax(0, 1fr); + } + + .connectors-row__actions { + grid-column: 2; + justify-content: flex-start; + } + + .connectors-details { + padding-left: 56px; + } + + .connectors-inspection__grid { + grid-template-columns: 1fr; + } +} + +@media (pointer: coarse) { + .connectors-panel [data-component="icon-button"], + .connectors-action, + .connectors-refresh, + .connectors-detail-action { + min-width: 44px; + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .connectors-panel [data-component="icon-button"], + .connectors-action, + .connectors-refresh, + .connectors-detail-action { + transition: none; + } + + .connectors-panel :is(button, [data-component="icon-button"]):active:not(:disabled) { + transform: none; + } +} diff --git a/frontend/workspace/src/components/settings/custom-credential.test.ts b/frontend/workspace/src/components/settings/custom-credential.test.ts new file mode 100644 index 00000000..35730a2d --- /dev/null +++ b/frontend/workspace/src/components/settings/custom-credential.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { customCredentialIdentity } from "./custom-credential" + +describe("custom credential identity", () => { + test("maps a service and field to the runtime environment contract", () => { + expect(customCredentialIdentity("My Research API", "Access_Token")).toEqual({ + ok: true, + id: "custom:my-research-api", + field: "access_token", + label: "My Research API", + }) + }) + + test("rejects field names that would persist but never produce an environment variable", () => { + for (const field of ["---", "api key", "1token", "token-name", "a".repeat(65)]) { + expect(customCredentialIdentity("Service", field).ok).toBe(false) + } + }) +}) diff --git a/frontend/workspace/src/components/settings/custom-credential.ts b/frontend/workspace/src/components/settings/custom-credential.ts new file mode 100644 index 00000000..4a248264 --- /dev/null +++ b/frontend/workspace/src/components/settings/custom-credential.ts @@ -0,0 +1,19 @@ +export type CustomCredentialIdentity = + | { ok: true; id: string; field: string; label: string } + | { ok: false; error: string } + +export function customCredentialIdentity(labelInput: string, fieldInput: string): CustomCredentialIdentity { + const label = labelInput.trim() + if (!label) return { ok: false, error: "Enter a service name." } + const slug = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + if (!slug || slug.length > 64) return { ok: false, error: "Use a service name with letters or numbers." } + + const field = (fieldInput.trim() || "api_key").toLowerCase() + if (!/^[a-z][a-z0-9_]{0,63}$/.test(field)) { + return { ok: false, error: "Environment fields must start with a letter and use only letters, numbers, or _." } + } + return { ok: true, id: `custom:${slug}`, field, label } +} diff --git a/frontend/workspace/src/components/settings/destructive-confirmations.test.ts b/frontend/workspace/src/components/settings/destructive-confirmations.test.ts new file mode 100644 index 00000000..8dc59628 --- /dev/null +++ b/frontend/workspace/src/components/settings/destructive-confirmations.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const COMPONENTS = [ + "CodexConnection.tsx", + "Connectors.tsx", + "CredentialServices.tsx", + "General.tsx", + "Network.tsx", + "ProviderKeys.tsx", + "Specialists.tsx", +] as const + +const source = (name: (typeof COMPONENTS)[number]) => + readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), "utf8") + +describe("settings destructive confirmations", () => { + test.each([...COMPONENTS])("uses the themed alert dialog in %s", (name) => { + const component = source(name) + + expect(component).toContain('import { useDialog } from "@synsci/ui/context/dialog"') + expect(component).toContain('import { confirmDialog } from "@/atlas/dialogs"') + expect(component).toContain("const dialog = useDialog()") + expect(component).toContain("await confirmDialog(dialog, {") + expect(component).toContain("danger: true") + expect(component).not.toContain("window.confirm") + }) +}) diff --git a/frontend/workspace/src/components/settings/models.css b/frontend/workspace/src/components/settings/models.css new file mode 100644 index 00000000..339f5b55 --- /dev/null +++ b/frontend/workspace/src/components/settings/models.css @@ -0,0 +1,534 @@ +.settings-models-panel { + --models-control-height: 32px; + --models-gap-1: 4px; + --models-gap-2: 8px; + --models-gap-3: 12px; + --models-gap-4: 16px; +} + +/* Models uses the same restrained surface stack as Compute. The panel should + read as one Settings system, not a nested set of independently bordered + widgets. */ +.settings-dialog .settings-models-panel .settings-card { + gap: 2px; + padding: var(--models-gap-1); + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: none; +} + +.settings-dialog .settings-models-panel .models-compact-row { + min-height: 56px; + gap: var(--models-gap-3); + padding: 10px var(--models-gap-3); + border-radius: var(--settings-radius-control); +} + +.models-inference, +.models-provider-keys { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--models-gap-2); +} + +.models-account-summary__identity, +.models-connection-identity, +.models-provider-identity, +.models-model-identity { + display: flex; + min-width: 0; + flex: 1 1 220px; + align-items: center; + gap: var(--models-gap-3); +} + +.settings-dialog .settings-models-panel .settings-row-icon { + width: 28px; + height: 28px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--icon-strong-base); + box-shadow: none; +} + +.models-row-action { + display: inline-flex; + max-width: 100%; + flex: 0 0 auto; + align-items: center; + margin-left: auto; +} + +.settings-dialog .settings-models-panel [data-component="button"] { + min-height: var(--models-control-height); + height: var(--models-control-height); + padding: 0 var(--models-gap-2); + border-radius: var(--settings-radius-control); + font-size: 12px; + box-shadow: none; + text-shadow: none; +} + +.settings-dialog .settings-models-panel [data-component="button"].models-primary-action[data-variant="primary"] { + border-color: transparent; + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: none; + text-shadow: none; +} + +.settings-dialog + .settings-models-panel + [data-component="button"].models-primary-action[data-variant="primary"]:hover:not(:disabled), +.settings-dialog + .settings-models-panel + [data-component="button"].models-primary-action[data-variant="primary"]:focus:not(:disabled) { + border-color: transparent; + background: var(--settings-primary-hover); +} + +.settings-dialog .settings-models-panel [data-component="button"].models-secondary-action[data-variant="secondary"] { + border-color: transparent; + background: transparent; + color: var(--text-base); +} + +.settings-dialog + .settings-models-panel + [data-component="button"].models-secondary-action[data-variant="secondary"]:hover:not(:disabled), +.settings-dialog + .settings-models-panel + [data-component="button"].models-secondary-action[data-variant="secondary"]:focus:not(:disabled) { + background: var(--settings-surface-hover); +} + +.settings-dialog .settings-models-panel :where(button, input):focus-visible, +.settings-dialog .settings-models-panel [data-slot="select-select-trigger"]:focus-visible { + outline-color: var(--border-focus); +} + +.models-routing { + min-width: 0; + padding: var(--models-gap-1); + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: none; +} + +.models-routing__options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2px; + padding: 2px; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} + +.settings-dialog .settings-models-panel .models-routing__option { + min-width: 0; + min-height: var(--models-control-height); + height: var(--models-control-height); + padding: 0 var(--models-gap-2); + border: 0; + border-radius: calc(var(--settings-radius-control) - 2px); + background: transparent; + color: var(--text-weak); + font-size: 12px; + font-weight: var(--font-weight-medium); + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.settings-dialog .settings-models-panel .models-routing__option:hover:not(:disabled) { + background: var(--settings-surface-hover); + color: var(--text-strong); +} + +.settings-dialog .settings-models-panel .models-routing__option:active:not(:disabled) { + transform: scale(0.98); +} + +.settings-dialog .settings-models-panel .models-routing__option[aria-pressed="true"] { + background: var(--settings-surface-hover); + color: var(--text-strong); + box-shadow: none; +} + +.models-routing__option-label { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; +} + +.models-routing__option-label > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.models-routing__description { + min-height: 28px; + margin: 0; + padding: 6px var(--models-gap-2) 2px; + color: var(--text-weak); + font-size: 12px; + line-height: 18px; + text-wrap: pretty; +} + +.models-routing__sync { + color: var(--text-weaker); +} + +.models-connection-row, +.models-provider-row { + justify-content: space-between; +} + +.models-connection-actions { + display: flex; + max-width: 100%; + flex: 0 0 auto; + align-items: center; + gap: var(--models-gap-2); + margin-left: auto; +} + +.settings-dialog .settings-models-panel .settings-status { + min-height: 20px; + padding: 1px 6px; + background: transparent; + font-size: 11px; + font-weight: var(--font-weight-regular); +} + +.models-provider-key-form { + display: grid !important; + grid-template-columns: minmax(160px, 0.65fr) minmax(220px, 1fr) auto; + align-items: end; + gap: var(--models-gap-2) !important; + padding: var(--models-gap-3) !important; +} + +.models-key-field { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--models-gap-1); +} + +.models-provider-select { + position: relative; + min-width: 0; +} + +.models-provider-select__mark { + position: absolute; + top: 50%; + left: 4px; + z-index: 1; + display: inline-flex; + pointer-events: none; + transform: translateY(-50%); +} + +.settings-dialog .settings-models-panel .models-provider-select [data-slot="select-select-trigger"] { + width: 100%; + min-width: 0; + min-height: var(--models-control-height); + height: var(--models-control-height); + padding-block: 0; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + box-shadow: none; +} + +.settings-dialog + .settings-models-panel + .models-provider-select + [data-slot="select-select-trigger"]:hover:not(:disabled), +.settings-dialog .settings-models-panel .models-provider-select [data-slot="select-select-trigger"][data-expanded] { + border-color: var(--border-base); + background: var(--settings-surface-muted); + box-shadow: none; +} + +.settings-dialog .settings-models-panel .models-key-input { + min-height: var(--models-control-height); + height: var(--models-control-height); + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + box-shadow: none; +} + +.settings-dialog .settings-models-panel .models-key-input:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); +} + +.settings-dialog .settings-models-panel .models-save-key { + align-self: end; + padding-inline: 10px; +} + +.models-provider-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.models-provider-meta { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--models-gap-1); +} + +.models-provider-source { + color: var(--text-weak); + font-size: 11px; +} + +.models-provider-source::before { + content: "·"; + margin-right: var(--models-gap-1); + color: var(--text-weaker); +} + +.models-provider-note { + max-width: min(100%, 260px); + margin-left: auto; + overflow-wrap: anywhere; + text-align: right; +} + +.settings-dialog .settings-models-panel .settings-provider-logo { + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--text-strong); + box-shadow: none; +} + +.settings-dialog .settings-models-panel .settings-provider-logo[data-size="small"] { + border-radius: calc(var(--settings-radius-control) - 2px); +} + +.settings-dialog .settings-models-panel .settings-defaults-card [data-slot="select-select-trigger"] { + min-width: 180px; + min-height: var(--models-control-height); + height: var(--models-control-height); + border-radius: var(--settings-radius-control); + background: transparent; +} + +.settings-dialog + .settings-models-panel + .settings-defaults-card + [data-slot="select-select-trigger"]:hover:not(:disabled), +.settings-dialog .settings-models-panel .settings-defaults-card [data-slot="select-select-trigger"][data-expanded] { + background: var(--settings-surface-hover); +} + +.models-default-option { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: var(--models-gap-2, 8px); +} + +.models-default-option__provider { + margin-left: auto; + overflow: hidden; + color: var(--text-weak); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-dialog .settings-models-panel .settings-list-header { + min-height: 32px; + padding: 0 var(--models-gap-3); + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} + +.settings-dialog .settings-models-panel .settings-model-row { + min-height: 52px; + padding-block: 6px; +} + +.settings-dialog .settings-models-panel .settings-icon-action { + width: var(--models-control-height); + min-width: var(--models-control-height); + height: var(--models-control-height); + padding: 0; + border-radius: var(--settings-radius-control); +} + +.settings-dialog .settings-models-panel .settings-filter-pill { + min-height: var(--models-control-height); + height: var(--models-control-height); + padding: 0 9px; + border-radius: var(--settings-radius-control); +} + +.settings-dialog .settings-models-panel .settings-filter-pill[aria-pressed="true"] { + background: var(--settings-surface-hover); +} + +.settings-dialog .settings-models-panel [data-component="switch"] { + min-height: var(--models-control-height); +} + +.settings-dialog .settings-models-panel [data-component="switch"] [data-slot="switch-control"] { + box-sizing: border-box; + width: 30px; + height: 18px; + padding: 1px; + border: 1px solid var(--border-weak-base); + border-radius: 999px; + background: var(--settings-surface-muted); +} + +.settings-dialog .settings-models-panel [data-component="switch"] [data-slot="switch-thumb"] { + box-sizing: border-box; + width: 14px; + height: 14px; + margin: 0; + border: 0; + border-radius: 50%; + background: var(--icon-invert-base); + transform: translateX(0); +} + +.settings-dialog .settings-models-panel [data-component="switch"][data-checked] [data-slot="switch-control"], +.settings-dialog + .settings-models-panel + [data-component="switch"][data-checked]:hover:not([data-disabled], [data-readonly]) + [data-slot="switch-control"] { + border-color: var(--settings-toggle-active); + background: var(--settings-toggle-active); +} + +.settings-dialog .settings-models-panel [data-component="switch"][data-checked] [data-slot="switch-thumb"] { + border: 0; + transform: translateX(12px); +} + +.models-provider-options[data-component="select-content"] { + min-width: 240px; + border-radius: var(--radius-md); + background: var(--surface-raised-stronger-non-alpha); + box-shadow: var(--shadow-lg); +} + +.models-provider-options[data-component="select-content"] [data-slot="select-select-content-list"] { + max-height: min(320px, 52vh); +} + +.models-provider-options[data-slot="select-select-item"] { + min-height: 32px; + border-radius: var(--radius-sm); +} + +.models-provider-options[data-slot="select-select-item"]:hover, +.models-provider-options[data-slot="select-select-item"][data-highlighted] { + background: var(--surface-raised-base-hover); +} + +.models-provider-options .settings-provider-logo, +.models-default-option .settings-provider-logo { + width: 24px; + height: 24px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: calc(var(--settings-radius-control) - 2px); + background: var(--settings-surface-muted); + color: var(--text-strong); + box-shadow: none; +} + +.settings-dialog .settings-models-panel .settings-model-row { + content-visibility: auto; + contain-intrinsic-size: auto 56px; +} + +.models-catalog-progress { + min-height: 36px; + margin: 0; + padding: 9px var(--models-gap-3); + color: var(--text-weaker); + font-size: 11px; + line-height: 18px; + text-align: center; +} + +@container settings-main (max-width: 680px) { + .models-provider-key-form { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + } + + .settings-dialog .settings-models-panel .models-save-key { + grid-column: 1 / -1; + justify-self: end; + } +} + +@container settings-main (max-width: 500px) { + .models-routing__options, + .models-provider-key-form { + grid-template-columns: 1fr; + } + + .settings-dialog .settings-models-panel .models-routing__option { + justify-content: flex-start; + } + + .models-routing__option-label { + justify-content: start; + } + + .models-connection-row, + .models-provider-row { + align-items: flex-start; + } + + .models-connection-actions, + .models-row-action, + .models-provider-note { + margin-left: 40px; + } + + .models-provider-note { + max-width: calc(100% - 40px); + text-align: left; + } + + .settings-dialog .settings-models-panel .models-save-key { + grid-column: auto; + justify-self: stretch; + } +} + +@media (pointer: coarse) { + .settings-dialog .settings-models-panel :where(button, [role="button"], [data-component="switch"]), + .settings-dialog .settings-models-panel :where(input, [data-slot="select-select-trigger"]) { + min-height: 44px; + } +} diff --git a/frontend/workspace/src/components/settings/network-domain.test.ts b/frontend/workspace/src/components/settings/network-domain.test.ts new file mode 100644 index 00000000..d2dc36e1 --- /dev/null +++ b/frontend/workspace/src/components/settings/network-domain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { canonicalNetworkDomain } from "./network-domain" + +describe("custom Network Settings domains", () => { + test("canonicalizes case, one trailing dot, and internationalized DNS names", () => { + expect(canonicalNetworkDomain("EXAMPLE.ORG.")).toEqual({ ok: true, domain: "example.org" }) + expect(canonicalNetworkDomain("münchen.example")).toEqual({ ok: true, domain: "xn--mnchen-3ya.example" }) + }) + + test("rejects non-host URL material and local or IP destinations", () => { + for (const value of [ + " https://example.org", + "https://example.org", + "example.org/path", + "example.org:443", + "user@example.org", + "*.example.org", + "127.0.0.1", + "127.1", + "::1", + "localhost", + "intranet", + "research.local", + ]) { + expect(canonicalNetworkDomain(value).ok).toBe(false) + } + }) +}) diff --git a/frontend/workspace/src/components/settings/network-domain.ts b/frontend/workspace/src/components/settings/network-domain.ts new file mode 100644 index 00000000..44c3f785 --- /dev/null +++ b/frontend/workspace/src/components/settings/network-domain.ts @@ -0,0 +1,43 @@ +export type NetworkDomainResult = { ok: true; domain: string } | { ok: false; error: string } + +export function canonicalNetworkDomain(input: string): NetworkDomainResult { + if (!input) return { ok: false, error: "Enter a domain name." } + if (input !== input.trim() || /\s/.test(input)) { + return { ok: false, error: "Domains cannot contain whitespace." } + } + if (/[\/@:?#*]/.test(input)) { + return { ok: false, error: "Enter a bare hostname without a scheme, path, port, credentials, or wildcard." } + } + + const lower = input.toLowerCase() + const withoutDot = lower.endsWith(".") ? lower.slice(0, -1) : lower + if (!withoutDot || withoutDot.endsWith(".")) return { ok: false, error: "Enter a valid DNS hostname." } + // Reject decimal-looking forms before URL parsing can normalize shorthand + // such as 127.1 into an IP literal. + if (/^\d+(?:\.\d+)*$/.test(withoutDot)) return { ok: false, error: "IP addresses are not allowed." } + + let hostname: string + try { + hostname = new URL(`http://${withoutDot}`).hostname.toLowerCase() + } catch { + return { ok: false, error: "Enter a valid DNS hostname." } + } + if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) { + return { ok: false, error: "Local hostnames are not allowed." } + } + if (!hostname.includes(".")) return { ok: false, error: "Enter a fully qualified hostname." } + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname.includes(":")) { + return { ok: false, error: "IP addresses are not allowed." } + } + if (hostname.length > 253) return { ok: false, error: "The hostname is too long." } + const labels = hostname.split(".") + if ( + labels.some( + (label) => + !label || label.length > 63 || !/^[a-z0-9-]+$/.test(label) || label.startsWith("-") || label.endsWith("-"), + ) + ) { + return { ok: false, error: "Enter a valid DNS hostname." } + } + return { ok: true, domain: hostname } +} diff --git a/frontend/workspace/src/components/settings/network-endpoint.ts b/frontend/workspace/src/components/settings/network-endpoint.ts new file mode 100644 index 00000000..8b302a2d --- /dev/null +++ b/frontend/workspace/src/components/settings/network-endpoint.ts @@ -0,0 +1,3 @@ +export function networkEndpoint(baseUrl: string) { + return `${baseUrl.replace(/\/+$/, "")}/settings/network` +} diff --git a/frontend/workspace/src/components/settings/network-write.test.ts b/frontend/workspace/src/components/settings/network-write.test.ts new file mode 100644 index 00000000..804c3f5f --- /dev/null +++ b/frontend/workspace/src/components/settings/network-write.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { commitNetworkState, type NetworkSettingsState } from "./network-write" + +const initial: NetworkSettingsState = { allowlistEnabled: false, enabled: [], custom: [] } + +function deferred<T>() { + let resolve!: (value: T) => void + const promise = new Promise<T>((done) => (resolve = done)) + return { promise, resolve } +} + +describe("Network Settings writes", () => { + test("serializes whole-state writes so an older response cannot overwrite a newer edit", async () => { + let state = initial + let saving = false + let error: string | undefined + let calls = 0 + const pending = deferred<NetworkSettingsState>() + const hooks = { + isSaving: () => saving, + state: () => state, + setState: (next: NetworkSettingsState) => (state = next), + setSaving: (next: boolean) => (saving = next), + setError: (next: string | undefined) => (error = next), + write: async () => { + calls++ + return pending.promise + }, + } + + const firstState = { ...initial, allowlistEnabled: true } + const first = commitNetworkState(firstState, hooks) + const second = await commitNetworkState({ ...firstState, custom: ["example.org"] }, hooks) + + expect(second).toEqual({ ok: false, busy: true }) + expect(calls).toBe(1) + expect(state).toEqual(firstState) + expect(saving).toBe(true) + expect(error).toBeUndefined() + + pending.resolve(firstState) + expect(await first).toEqual({ ok: true }) + expect(saving).toBe(false) + }) + + test("restores the last confirmed state and exposes a failed write", async () => { + let state = initial + let saving = false + let error: string | undefined + const result = await commitNetworkState( + { ...initial, custom: ["example.org"] }, + { + isSaving: () => saving, + state: () => state, + setState: (next) => (state = next), + setSaving: (next) => (saving = next), + setError: (next) => (error = next), + write: async () => { + throw new Error("disk is read-only") + }, + }, + ) + + expect(result).toEqual({ ok: false, error: "disk is read-only" }) + expect(state).toEqual(initial) + expect(saving).toBe(false) + expect(error).toBe("disk is read-only") + }) +}) diff --git a/frontend/workspace/src/components/settings/network-write.ts b/frontend/workspace/src/components/settings/network-write.ts new file mode 100644 index 00000000..f9ce053d --- /dev/null +++ b/frontend/workspace/src/components/settings/network-write.ts @@ -0,0 +1,39 @@ +export type NetworkSettingsState = { + allowlistEnabled: boolean + enabled: string[] + custom: string[] +} + +type NetworkWriteHooks = { + isSaving: () => boolean + state: () => NetworkSettingsState + setState: (state: NetworkSettingsState) => void + setSaving: (saving: boolean) => void + setError: (error: string | undefined) => void + write: (state: NetworkSettingsState) => Promise<NetworkSettingsState> +} + +export async function commitNetworkState( + next: NetworkSettingsState, + hooks: NetworkWriteHooks, +): Promise<{ ok: true } | { ok: false; busy: true } | { ok: false; error: string }> { + // The endpoint replaces the whole state. Reject a second write while the + // first is in flight so an older response can never overwrite a newer edit. + if (hooks.isSaving()) return { ok: false, busy: true } + + const previous = hooks.state() + hooks.setSaving(true) + hooks.setError(undefined) + hooks.setState(next) + try { + hooks.setState(await hooks.write(next)) + return { ok: true } + } catch (error) { + hooks.setState(previous) + const value = error instanceof Error ? error.message : String(error) + hooks.setError(value) + return { ok: false, error: value } + } finally { + hooks.setSaving(false) + } +} diff --git a/frontend/workspace/src/components/settings/panel-layout.test.ts b/frontend/workspace/src/components/settings/panel-layout.test.ts new file mode 100644 index 00000000..9fb9f992 --- /dev/null +++ b/frontend/workspace/src/components/settings/panel-layout.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" + +const root = new URL("./", import.meta.url) +const read = (path: string) => Bun.file(new URL(path, root)).text() + +const panels = [ + "Models.tsx", + "Connectors.tsx", + "Specialists.tsx", + "Compute.tsx", + "Network.tsx", + "Permissions.tsx", + "Sandbox.tsx", + "Credentials.tsx", + "CredentialServices.tsx", + "ProviderKeys.tsx", + "Storage.tsx", + "General.tsx", +] + +describe("settings panel layout contract", () => { + test("lets shared card and row primitives own their boundaries", async () => { + for (const path of panels) { + const source = await read(path) + const cards = source.match(/class="[^"]*\bsettings-card\b[^"]*"/g) ?? [] + const rows = source.match(/class="[^"]*\bsettings-row\b[^"]*"/g) ?? [] + + for (const card of cards) { + expect(card.match(/\bborder\s/), `${path}: ${card}`).toBeNull() + expect(card.includes("border-border-weak-base"), `${path}: ${card}`).toBe(false) + } + for (const row of rows) { + expect(row.match(/\bborder-[bt](?:\s|\b)/), `${path}: ${row}`).toBeNull() + expect(row.includes("border-border-weak-base/"), `${path}: ${row}`).toBe(false) + } + expect(source.includes('class="settings-card settings-row'), path).toBe(false) + expect(source.includes('class="settings-list-row settings-row'), path).toBe(false) + } + }) + + test("keeps shared controls and panel containers shrinkable", async () => { + const source = await read("_shared.tsx") + + expect(source).toContain('class="flex min-h-0 min-w-0 flex-col h-full overflow-y-auto no-scrollbar"') + expect(source).toContain('class="settings-page-body min-w-0"') + expect(source).toContain('class="min-w-0 flex-1 bg-transparent') + expect(source).toContain('class="settings-toolbar min-w-0"') + }) + + test("guards long settings values and dense action groups", async () => { + const network = await read("Network.tsx") + const providers = await read("ProviderKeys.tsx") + const storage = await read("Storage.tsx") + const connectors = await read("Connectors.tsx") + const connectorStyles = await read("connectors.css") + + expect(network).toContain("max-w-full break-all whitespace-normal") + expect(network).toContain("min-w-0 flex-1 basis-[220px] font-mono") + expect(providers).toContain("min-w-0 flex-1 basis-[220px]") + expect(storage).toContain("min-w-0 flex-1 basis-[240px]") + expect(connectors).toContain('class="connectors-row__actions"') + expect(connectorStyles).toContain("--connectors-row-columns: 32px minmax(180px, 1fr) minmax(92px, auto) auto") + expect(connectorStyles).toContain("grid-template-columns: var(--connectors-row-columns)") + expect(connectorStyles).toContain("justify-content: flex-end") + }) +}) diff --git a/frontend/workspace/src/components/settings/panel-stack.fixture.tsx b/frontend/workspace/src/components/settings/panel-stack.fixture.tsx new file mode 100644 index 00000000..f0770f92 --- /dev/null +++ b/frontend/workspace/src/components/settings/panel-stack.fixture.tsx @@ -0,0 +1,29 @@ +import { createSignal } from "solid-js" +import { SettingsPanelStack } from "./panel-stack" + +export function createPanelStackFixture(onReady: (select: (id: "models" | "network") => void) => void) { + const [active, setActive] = createSignal<"models" | "network">("models") + const mounts = { models: 0, network: 0 } + const Models = () => { + mounts.models += 1 + return <input aria-label="Model filter" value="remember me" /> + } + const Network = () => { + mounts.network += 1 + return <div>Network settings</div> + } + onReady(setActive) + + return { + mounts, + view: () => ( + <SettingsPanelStack + active={active} + panels={() => [ + { id: "models", component: Models }, + { id: "network", component: Network }, + ]} + /> + ), + } +} diff --git a/frontend/workspace/src/components/settings/panel-stack.test.tsx b/frontend/workspace/src/components/settings/panel-stack.test.tsx new file mode 100644 index 00000000..5bf393aa --- /dev/null +++ b/frontend/workspace/src/components/settings/panel-stack.test.tsx @@ -0,0 +1,75 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import { fileURLToPath } from "node:url" +import { createServer } from "vite" +import solid from "vite-plugin-solid" +import type { JSX } from "solid-js" + +const cleanups: Array<() => void> = [] +const server = await createServer({ + root: fileURLToPath(new URL("../../..", import.meta.url)), + mode: "production", + logLevel: "silent", + plugins: [solid({ ssr: false, dev: false })], + server: { middlewareMode: true }, + appType: "custom", + resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, + ssr: { + noExternal: true, + resolve: { conditions: ["browser", "production"] }, + }, +}) +const [fixture, web] = await Promise.all([ + server.ssrLoadModule("/src/components/settings/panel-stack.fixture.tsx") as Promise< + typeof import("./panel-stack.fixture") + >, + server.ssrLoadModule("solid-js/web") as Promise<typeof import("solid-js/web")>, +]) + +afterAll(() => server.close()) +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + document.body.replaceChildren() +}) + +const mount = (view: () => JSX.Element) => { + const host = document.createElement("div") + document.body.append(host) + cleanups.push(web.render(view, host)) + return host +} + +describe("SettingsPanelStack", () => { + test("keeps visited panels mounted while exposing only the active panel", async () => { + let select!: (id: "models" | "network") => void + const harness = fixture.createPanelStackFixture((next) => (select = next)) + const host = mount(harness.view) + + const models = host.querySelector<HTMLElement>('[data-settings-panel="models"]')! + const network = host.querySelector<HTMLElement>('[data-settings-panel="network"]')! + expect(models.hidden).toBe(false) + expect(models.hasAttribute("inert")).toBe(false) + expect(models.getAttribute("aria-hidden")).toBeNull() + expect(network.hidden).toBe(true) + expect(network.hasAttribute("inert")).toBe(true) + expect(network.getAttribute("aria-hidden")).toBe("true") + + const modelFilter = host.querySelector<HTMLInputElement>('[aria-label="Model filter"]')! + modelFilter.focus() + expect(document.activeElement).toBe(modelFilter) + + select("network") + await Promise.resolve() + expect(models.hidden).toBe(true) + expect(models.hasAttribute("inert")).toBe(true) + expect(models.getAttribute("aria-hidden")).toBe("true") + expect(network.hidden).toBe(false) + expect(network.hasAttribute("inert")).toBe(false) + expect(network.getAttribute("aria-hidden")).toBeNull() + expect(document.activeElement).toBe(network) + select("models") + + expect(harness.mounts).toEqual({ models: 1, network: 1 }) + expect(modelFilter.value).toBe("remember me") + expect(host.querySelector(".settings-panel-loading")).toBeNull() + }) +}) diff --git a/frontend/workspace/src/components/settings/panel-stack.tsx b/frontend/workspace/src/components/settings/panel-stack.tsx new file mode 100644 index 00000000..05c73c9b --- /dev/null +++ b/frontend/workspace/src/components/settings/panel-stack.tsx @@ -0,0 +1,73 @@ +import { Dynamic } from "solid-js/web" +import { For, Suspense, createEffect, type Accessor, type Component } from "solid-js" + +export interface SettingsPanelStackItem<Id extends string = string> { + id: Id + component: Component +} + +/** + * Retains every settings panel after its first visit. + * + * The shell only adds a panel after its module has preloaded, so Suspense is a + * last-resort guard rather than a visible navigation state. Hidden panels stay + * mounted: local form state, scroll position, resources, and subscriptions do + * not restart when the user moves between settings sections. + */ +export function SettingsPanelStack<Id extends string>(props: { + active: Accessor<Id> + panels: Accessor<SettingsPanelStackItem<Id>[]> +}) { + const slots = new Map<Id, HTMLElement>() + + createEffect(() => { + const active = props.active() + + queueMicrotask(() => { + if (props.active() !== active) return + + const focused = document.activeElement as HTMLElement | null + const focusedPanel = focused?.closest<HTMLElement>("[data-settings-panel]") + if (!focusedPanel || focusedPanel.dataset.settingsPanel === active) return + + slots.get(active)?.focus({ preventScroll: true }) + }) + }) + + return ( + <For each={props.panels()}> + {(panel) => ( + <section + ref={(element) => slots.set(panel.id, element)} + class="settings-panel-slot" + data-settings-panel={panel.id} + hidden={props.active() !== panel.id} + aria-hidden={props.active() !== panel.id ? "true" : undefined} + inert={props.active() !== panel.id} + tabIndex={-1} + > + <Suspense + fallback={ + <div class="settings-panel-loading" role="status" aria-label="Loading settings"> + <div class="settings-panel-loading__header"> + <span class="settings-panel-loading__line" data-size="title" /> + <span class="settings-panel-loading__line" data-size="copy" /> + </div> + <div class="settings-panel-loading__body"> + <span class="settings-panel-loading__line" data-size="label" /> + <div class="settings-panel-loading__rows" aria-hidden="true"> + <span /> + <span /> + <span /> + </div> + </div> + </div> + } + > + <Dynamic component={panel.component} /> + </Suspense> + </section> + )} + </For> + ) +} diff --git a/frontend/workspace/src/components/settings/permission-defaults.ts b/frontend/workspace/src/components/settings/permission-defaults.ts new file mode 100644 index 00000000..b757f76f --- /dev/null +++ b/frontend/workspace/src/components/settings/permission-defaults.ts @@ -0,0 +1,80 @@ +export type PermissionAction = "allow" | "ask" | "deny" +type PermissionObject = Record<string, PermissionAction> +type PermissionValue = PermissionAction | PermissionObject | string[] | undefined +type PermissionMap = Record<string, PermissionValue> + +const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"]) + +// Keep the Settings readout aligned with Agent's actual base rules. Most +// tools default to allow, but these two are explicitly ask-by-default in the +// backend. Showing Allow here when no user override existed made the panel +// contradict the permission engine until the user saved a value. +const RUNTIME_DEFAULTS: Partial<Record<string, PermissionAction>> = { + external_directory: "ask", + doom_loop: "ask", +} + +export function permissionDefaultFor(id: string): PermissionAction { + return RUNTIME_DEFAULTS[id] ?? "allow" +} + +function getAction(value: unknown): PermissionAction | undefined { + if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction + return undefined +} + +function ruleDefault(value: unknown): PermissionAction | undefined { + const action = getAction(value) + if (action) return action + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return getAction((value as Record<string, unknown>)["*"]) +} + +function toMap(value: unknown): PermissionMap { + if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap + const action = getAction(value) + return action ? { "*": action } : {} +} + +export function permissionActionFor(permission: unknown, id: string): PermissionAction { + const map = toMap(permission) + return ruleDefault(map[id]) ?? ruleDefault(map["*"]) ?? permissionDefaultFor(id) +} + +export function permissionChange(permission: unknown, id: string, action: PermissionAction) { + const map = toMap(permission) + const existing = map[id] + const nextValue = + existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action + return { + optimistic: { ...map, [id]: nextValue }, + patch: { [id]: nextValue }, + } +} + +export async function commitPermissionDefault( + id: string, + action: PermissionAction, + hooks: { + isBusy: () => boolean + permission: () => unknown + setPermission: (permission: unknown) => void + setBusy: (busy: boolean) => void + write: (patch: Record<string, PermissionValue>) => Promise<unknown> + }, +): Promise<{ ok: true } | { ok: false; busy: true } | { ok: false; error: string }> { + if (hooks.isBusy()) return { ok: false, busy: true } + const before = hooks.permission() + const change = permissionChange(before, id, action) + hooks.setBusy(true) + hooks.setPermission(change.optimistic) + try { + await hooks.write(change.patch) + return { ok: true } + } catch (error) { + hooks.setPermission(before) + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + hooks.setBusy(false) + } +} diff --git a/frontend/workspace/src/components/settings/preference-panels.css b/frontend/workspace/src/components/settings/preference-panels.css new file mode 100644 index 00000000..82dd0fde --- /dev/null +++ b/frontend/workspace/src/components/settings/preference-panels.css @@ -0,0 +1,657 @@ +/* + * Calm grouped settings surfaces for the system and app preference panels. + * + * The panel components opt into this layer with `.settings-preferences-panel`. + * It intentionally keeps one semantic surface around each related group, uses + * spacing instead of nested separators, and leaves advanced controls behind + * explicit disclosure actions. + */ +.settings-preferences-panel { + --settings-preferences-accent: var(--settings-accent); + min-width: 0; + min-height: 100%; +} + +.settings-preferences-panel .settings-page-header { + padding-bottom: var(--settings-space-3); + border-bottom: 0; +} + +.settings-preferences-panel .settings-page-header h2 { + text-wrap: balance; +} + +.settings-preferences-panel :where(.settings-page-header p, .settings-section-heading p, .settings-row-copy span) { + text-wrap: pretty; +} + +.settings-preferences-panel .settings-page-body { + gap: var(--settings-space-5); + padding-top: var(--settings-space-3); +} + +.settings-preferences-panel .settings-section { + gap: var(--settings-space-3); +} + +.settings-preferences-panel .settings-card { + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: none; +} + +.settings-preferences-panel .settings-list-item + .settings-list-item { + border-top: 0; +} + +.settings-preferences-panel .settings-row, +.settings-preference-row { + position: relative; + min-height: 56px; + gap: var(--settings-space-3); + padding: 10px var(--settings-space-3); + border-bottom: 0; +} + +.settings-preference-icon { + width: 30px; + height: 30px; + display: inline-flex; + flex: 0 0 30px; + align-items: center; + justify-content: center; + border-radius: var(--settings-radius-control); + background: var(--surface-base-hover); + color: var(--icon-strong-base); +} + +.settings-preference-icon[data-tone="success"] { + background: color-mix(in srgb, var(--icon-success-base) 11%, var(--settings-surface-muted)); + color: var(--icon-success-base); +} + +.settings-preference-icon[data-tone="warning"] { + background: color-mix(in srgb, var(--text-warning-base) 12%, var(--settings-surface-muted)); + color: var(--text-warning-base); +} + +.settings-row-copy { + min-width: 0; + display: flex; + flex: 1 1 220px; + flex-direction: column; + gap: 2px; +} + +.settings-row-copy strong { + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); +} + +.settings-row-copy span { + color: var(--text-weak); + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); +} + +.settings-preference-status { + min-height: 24px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 0; + border-radius: 999px; + background: var(--settings-surface-muted); + color: var(--text-weak); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 16px; + font-variant-numeric: tabular-nums; +} + +.settings-preference-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.72; +} + +.settings-preference-status[data-tone="success"] { + background: color-mix(in srgb, var(--icon-success-base) 10%, transparent); + color: var(--icon-success-base); +} + +.settings-preference-status[data-tone="warning"] { + background: color-mix(in srgb, var(--text-warning-base) 10%, transparent); + color: var(--text-warning-base); +} + +.settings-preference-action { + min-width: 32px; + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--settings-space-2); + padding: 0 var(--settings-space-3); + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--text-strong); + font-size: 12px; + font-weight: var(--font-weight-medium); + transition: + background-color 150ms ease, + color 150ms ease, + opacity 150ms ease; +} + +.settings-preference-action:hover:not(:disabled) { + background: var(--settings-surface-hover); +} + +.settings-preference-action[data-variant="primary"] { + background: var(--settings-primary); + color: var(--settings-on-primary); +} + +.settings-preference-action[data-variant="primary"]:hover:not(:disabled) { + background: var(--settings-primary-hover); +} + +.settings-preference-action[data-variant="quiet"] { + background: transparent; + color: var(--text-weak); +} + +.settings-preference-action[data-variant="danger"] { + background: transparent; + color: var(--text-danger); +} + +.settings-preference-action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.settings-preferences-panel .settings-icon-action { + width: 32px; + height: 32px; + border-radius: var(--settings-radius-control); + transition: + background-color 150ms ease, + color 150ms ease, + opacity 150ms ease; +} + +.settings-preferences-panel :where(button, [role="button"], input, select, textarea):focus-visible { + transition: none; +} + +.settings-preference-disclosure { + padding: 0 var(--settings-space-4) var(--settings-space-3) 56px; +} + +.settings-preference-domain-list { + display: flex; + flex-wrap: wrap; + gap: 6px var(--settings-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.settings-preference-domain-list code { + color: var(--text-weak); + font-size: 11px; + line-height: 18px; + font-variant-numeric: slashed-zero; +} + +.settings-inline-editor { + display: grid; + gap: var(--settings-space-3); + padding: 2px var(--settings-space-4) var(--settings-space-4) 56px; +} + +.settings-inline-editor__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--settings-space-2); +} + +.settings-preferences-panel .settings-field { + border-color: transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + box-shadow: none; + transition: + background-color 150ms ease, + box-shadow 150ms ease; +} + +.settings-preferences-panel .settings-field:focus { + border-color: transparent; + background: var(--settings-surface-hover); + box-shadow: none; +} + +.settings-preferences-panel .settings-alert { + border: 0; + border-radius: var(--settings-radius-control); + box-shadow: none; +} + +.settings-preferences-panel .settings-alert[data-tone="success"] { + background: color-mix(in srgb, var(--icon-success-base) 7%, transparent); + color: var(--text-strong); +} + +.settings-panel-loading-copy { + min-height: 60px; + display: flex; + align-items: center; + padding: var(--settings-space-3) var(--settings-space-4); + color: var(--text-weak); + font-size: 12px; +} + +.settings-empty-copy { + display: block; + padding: var(--settings-space-4); + color: var(--text-weak); + font-size: 12px; + text-wrap: pretty; +} + +/* Network follows the same flat row language as Compute. Repeated service + icons were removed; the large disclosure target and switches carry the + interaction without another visual column. */ +.settings-preferences-panel--network .settings-page-header__inner { + position: relative; +} + +.settings-preferences-panel--network .settings-network-save-state { + position: absolute; + top: 2px; + right: 0; + min-width: 48px; + color: var(--text-weak); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 18px; + text-align: right; +} + +.settings-preferences-panel--network .settings-network-loading-rows { + gap: 2px; + padding: 4px; + border: 0; + background: var(--settings-surface); + box-shadow: none; +} + +.settings-preferences-panel--network .settings-network-loading-rows > span { + height: 56px; + background: var(--settings-surface-muted); +} + +.settings-preferences-panel--network .settings-network-loading-rows--domains > span { + height: 44px; +} + +.settings-preferences-panel--network .settings-network-loading-rows--domains > span:last-child { + height: 48px; +} + +.settings-preferences-panel--network .settings-network-policy-row, +.settings-preferences-panel--network .settings-network-group-row, +.settings-preferences-panel--network .settings-network-domain-row { + flex-wrap: nowrap; +} + +.settings-preferences-panel--network .settings-network-policy-row { + min-height: 60px; + padding-block: var(--settings-space-2); +} + +.settings-preferences-panel--network .settings-network-group-row { + min-height: 56px; + padding-block: var(--settings-space-2); +} + +.settings-preferences-panel--network .settings-network-domain-row { + min-height: 44px; + padding-block: 6px; +} + +.settings-preferences-panel--network .settings-network-disclosure { + min-width: 0; + min-height: 32px; + display: flex; + flex: 1 1 260px; + align-items: center; + align-self: stretch; + gap: 10px; + margin: 0; + padding: 0; + border-radius: 0; + color: var(--text-strong); + text-align: left; + transition: color 150ms ease; +} + +.settings-preferences-panel--network .settings-network-disclosure:hover { + color: var(--text-base); +} + +.settings-preferences-panel--network .settings-network-disclosure .settings-row-copy { + text-align: left; +} + +.settings-preferences-panel--network .settings-network-disclosure__icon { + flex: 0 0 auto; + color: var(--icon-weak-base); + transition: color 150ms ease; +} + +.settings-preferences-panel--network .settings-network-disclosure:hover .settings-network-disclosure__icon { + color: var(--icon-strong-base); +} + +.settings-preferences-panel--network .settings-preference-disclosure { + padding: 0 var(--settings-space-4) var(--settings-space-3) 38px; +} + +.settings-preferences-panel--network .settings-network-domain-value { + flex: 1 1 220px; + font-variant-numeric: slashed-zero; +} + +.settings-preferences-panel--network .settings-network-add-row { + min-height: 48px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + padding-block: var(--settings-space-2); +} + +.settings-preferences-panel--network .settings-network-empty-copy { + min-height: 40px; + display: flex; + align-items: center; + padding-block: var(--settings-space-2); +} + +/* Compute uses one calm surface hierarchy and lets action groups wrap before + they squeeze the operational copy into a narrow right-aligned column. */ +.settings-compute-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--settings-space-4); + padding: var(--settings-space-4); +} + +.settings-compute-provider-row, +.settings-compute-summary-row, +.settings-compute-host-row { + display: flex; + min-width: 0; + align-items: center; + gap: var(--settings-space-3); +} + +.settings-compute-provider-row { + flex-wrap: wrap; + justify-content: space-between; +} + +.settings-compute-summary-row, +.settings-compute-host-row { + flex-wrap: nowrap; +} + +.settings-compute-summary-action { + max-width: 100%; + flex: 0 0 auto; + margin-left: auto; +} + +.settings-compute-actions, +.settings-compute-host-actions { + display: flex; + max-width: 100%; + flex-wrap: wrap; + align-items: center; + gap: var(--settings-space-2); +} + +.settings-compute-actions { + justify-content: flex-end; +} + +.settings-compute-remote { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--settings-space-3); +} + +.settings-compute-host-copy { + min-width: 0; + display: flex; + flex: 1 1 320px; + align-items: flex-start; + gap: var(--settings-space-3); +} + +.settings-compute-host-actions { + flex: 0 0 auto; + justify-content: flex-end; + margin-left: auto; +} + +.settings-permission-defaults[data-expanded="false"] .settings-card > .settings-row:nth-child(n + 7) { + display: none; +} + +.settings-disclosure-footer { + display: flex; + justify-content: center; + padding-top: var(--settings-space-2); +} + +.settings-disclosure-group, +.settings-general-extras { + display: flex; + min-width: 0; + flex-direction: column; +} + +.settings-disclosure-group { + gap: var(--settings-space-2); +} + +.settings-general-extras { + gap: var(--settings-space-5); +} + +.settings-general-extras[data-expanded="false"] > .settings-section:nth-child(n + 3) { + display: none; +} + +.settings-storage-location { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--settings-space-3); + padding: var(--settings-space-4); +} + +.settings-storage-path, +.settings-storage-size, +.settings-storage-metric { + font-variant-numeric: tabular-nums; +} + +.settings-storage-path { + font-variant-numeric: slashed-zero; +} + +.settings-storage-usage-row { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + column-gap: var(--settings-space-3); + row-gap: var(--settings-space-2); +} + +.settings-storage-meter { + grid-column: 2 / -1; + height: 3px; + overflow: hidden; + border-radius: 999px; + background: var(--settings-surface-muted); +} + +.settings-storage-meter > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--surface-brand-base); +} + +.settings-sandbox-checks { + display: grid; + gap: var(--settings-space-2); + padding: 0 var(--settings-space-4) var(--settings-space-4) 56px; +} + +.settings-sandbox-check { + display: grid; + grid-template-columns: 18px minmax(0, auto) minmax(0, 1fr); + align-items: start; + gap: var(--settings-space-2); + color: var(--text-strong); + font-size: 12px; + line-height: 18px; +} + +.settings-sandbox-check__detail { + color: var(--text-weak); + text-wrap: pretty; +} + +.settings-account-value { + max-width: min(360px, 100%); + color: var(--text-strong); + font-size: 13px; + text-align: right; + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} + +@container settings-main (max-width: 680px) { + .settings-compute-host-row { + align-items: flex-start; + flex-wrap: wrap; + } + + .settings-compute-host-actions { + width: 100%; + justify-content: flex-start; + margin-left: 0; + padding-left: calc(32px + var(--settings-space-3)); + } + + .settings-compute-actions { + justify-content: flex-start; + } +} + +@container settings-main (max-width: 560px) { + .settings-preferences-panel .settings-row, + .settings-preference-row, + .settings-storage-location { + align-items: flex-start; + flex-wrap: wrap; + } + + .settings-preference-row__actions, + .settings-storage-location__actions { + width: 100%; + padding-left: 42px; + } + + .settings-inline-editor, + .settings-preference-disclosure, + .settings-sandbox-checks { + padding-left: var(--settings-space-4); + } + + .settings-account-value { + width: 100%; + padding-left: 42px; + text-align: left; + } + + .settings-preferences-panel--network .settings-network-policy-row, + .settings-preferences-panel--network .settings-network-group-row, + .settings-preferences-panel--network .settings-network-domain-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .settings-preferences-panel--network .settings-network-disclosure { + width: 100%; + } + + .settings-preferences-panel--network .settings-network-group-description { + block-size: var(--settings-leading-helper); + max-block-size: var(--settings-leading-helper); + display: block; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + white-space: nowrap; + } + + .settings-compute-summary-action { + width: 100%; + margin-left: 0; + padding-left: calc(32px + var(--settings-space-3)); + } +} + +@container settings-main (max-width: 420px) { + .settings-preferences-panel--network .settings-network-add-row { + grid-template-columns: minmax(0, 1fr); + } + + .settings-preferences-panel--network .settings-network-add-row .settings-preference-action { + justify-self: start; + } + + .settings-compute-host-actions, + .settings-compute-summary-action { + padding-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .settings-preferences-panel *, + .settings-preferences-panel *::before, + .settings-preferences-panel *::after { + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/workspace/src/components/settings/preference-panels.test.ts b/frontend/workspace/src/components/settings/preference-panels.test.ts new file mode 100644 index 00000000..67277031 --- /dev/null +++ b/frontend/workspace/src/components/settings/preference-panels.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const panelNames = ["Network", "Permissions", "Storage", "Sandbox", "General"] as const +const source = (name: (typeof panelNames)[number]) => + readFileSync(fileURLToPath(new URL(`./${name}.tsx`, import.meta.url)), "utf8") +const compute = readFileSync(fileURLToPath(new URL("./Compute.tsx", import.meta.url)), "utf8") +const styles = readFileSync(fileURLToPath(new URL("./preference-panels.css", import.meta.url)), "utf8") + +describe("minimal grouped settings panels", () => { + test.each([...panelNames])("opts %s into the shared preference surface", (name) => { + const component = source(name) + + expect(component).toContain('import "./preference-panels.css"') + expect(component).toContain("settings-preferences-panel") + expect(component).toContain("settings-preferences-card") + }) + + test("uses comfortable targets, fast pointer feedback, and instant keyboard focus", () => { + expect(styles).toContain("min-width: 32px") + expect(styles).toContain("min-height: 32px") + expect(styles).toContain("150ms ease") + expect(styles).toContain(":focus-visible") + expect(styles).toContain("transition: none") + }) + + test("uses progressive disclosure and aligned numeric metrics", () => { + expect(source("Permissions")).toContain("Show all tool defaults") + expect(source("Permissions")).toContain("aria-expanded={showAllDefaults()}") + expect(source("General")).toContain("Show sound and update settings") + expect(source("General")).toContain("aria-expanded={showAdvanced()}") + expect(styles).toContain('data-expanded="false"') + expect(styles).toContain("font-variant-numeric: tabular-nums") + }) + + test("keeps the operational settings paths and actions intact", () => { + expect(source("Network")).toContain("commitNetworkState") + expect(source("Permissions")).toContain("permission.standing.revoke") + expect(source("Permissions")).toContain("project.trust.update") + expect(source("Storage")).toContain('method: "POST"') + expect(source("Storage")).toContain('method: "DELETE"') + expect(source("Storage")).toContain('role="progressbar"') + expect(source("Sandbox")).toContain('call<SelfTest>("/test", { method: "POST" })') + expect(source("Sandbox")).toContain('aria-live="polite"') + expect(source("General")).toContain("sdk.client.account.logout()") + expect(source("General")).toContain('settingsApi<Preferences>(base(), fetchFn(), "/settings/preferences"') + }) + + test("avoids the previous nested form divider in storage", () => { + expect(source("Storage")).not.toContain("border-t border-border-weak-base") + expect(styles).toContain("spacing instead of nested separators") + expect(styles).not.toContain("settings-row:not(:last-child)::after") + }) + + test("keeps Network and Compute on the same flat responsive surface language", () => { + expect(source("Network")).toContain("settings-network-disclosure") + expect(compute).toContain("settings-preferences-panel--compute") + expect(styles).toMatch(/\.settings-preferences-panel \.settings-card\s*\{[^}]*border: 0;/s) + expect(styles).toContain(".settings-compute-host-actions") + expect(styles).toContain("@container settings-main (max-width: 680px)") + expect(styles).not.toContain("border: 1px solid var(--settings-border);") + }) +}) diff --git a/frontend/workspace/src/components/settings/preference-write.ts b/frontend/workspace/src/components/settings/preference-write.ts new file mode 100644 index 00000000..d70e0b7d --- /dev/null +++ b/frontend/workspace/src/components/settings/preference-write.ts @@ -0,0 +1,11 @@ +export async function commitPreference<T>( + write: () => Promise<T>, + apply: (value: T) => void, +): Promise<{ ok: true } | { ok: false; error: string }> { + try { + apply(await write()) + return { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/frontend/workspace/src/components/settings/registry-contract.test.ts b/frontend/workspace/src/components/settings/registry-contract.test.ts new file mode 100644 index 00000000..2cde0f3a --- /dev/null +++ b/frontend/workspace/src/components/settings/registry-contract.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { SETTINGS_PANELS, SETTINGS_PANEL_IDS, SETTINGS_SECTIONS } from "./registry" + +const root = new URL("./", import.meta.url) +const modules: Record<(typeof SETTINGS_PANEL_IDS)[number], string> = { + models: "Models", + skills: "Skills", + connectors: "Connectors", + specialists: "Specialists", + compute: "Compute", + network: "Network", + permissions: "Permissions", + sandbox: "Sandbox", + credentials: "Credentials", + storage: "Storage", + general: "General", +} + +describe("settings registry source contract", () => { + test("enumerates every reachable panel once and in rail order", () => { + expect(SETTINGS_PANELS.map((panel) => panel.id)).toEqual([...SETTINGS_PANEL_IDS]) + expect(new Set(SETTINGS_PANELS.map((panel) => panel.id)).size).toBe(SETTINGS_PANEL_IDS.length) + + for (const section of SETTINGS_SECTIONS) { + expect( + SETTINGS_PANELS.some((panel) => panel.section === section.id), + section.id, + ).toBe(true) + } + }) + + test("keeps every panel inside the shared settings frame", async () => { + for (const id of SETTINGS_PANEL_IDS) { + const source = await Bun.file(new URL(`${modules[id]}.tsx`, root)).text() + if (id === "skills") { + expect(source, id).toContain("<SkillsFrame>") + continue + } + expect(source, id).toContain("<PanelScroll>") + expect(source, id).toContain("<PanelHeader") + expect(source, id).toContain("<PanelBody>") + } + }) + + test("keeps nested model and general surfaces in the audited source set", async () => { + const models = await Bun.file(new URL("Models.tsx", root)).text() + const general = await Bun.file(new URL("General.tsx", root)).text() + + expect(models).toContain("<ManagedInference") + expect(models).toContain("<CodexConnection") + expect(models).toContain("<ProviderKeys") + expect(general).toContain("<AppearanceSections") + }) +}) diff --git a/frontend/workspace/src/components/settings/registry.ts b/frontend/workspace/src/components/settings/registry.ts index 939048be..9ecb48c3 100644 --- a/frontend/workspace/src/components/settings/registry.ts +++ b/frontend/workspace/src/components/settings/registry.ts @@ -4,7 +4,10 @@ import type { IconProps } from "@synsci/ui/icon" // ── Panel contract ────────────────────────────────────────────────────────── // // Every settings panel is a lazily-loaded SolidJS component keyed by a stable -// `id`. Panel authors own exactly one file — `components/settings/<Panel>.tsx` +// `id`. The shell preloads the default panel before the dialog opens, warms +// likely destinations during idle or navigation intent, and retains panels +// after their first visit. Panel authors own exactly one file — +// `components/settings/<Panel>.tsx` // — and `export default` a `Component`. The shell (dialog-settings.tsx) renders // the header (back/forward + title + expand/close) and the left rail from this // registry; the panel component only renders its own scrollable body. @@ -22,18 +25,24 @@ import type { IconProps } from "@synsci/ui/icon" export type SettingsSection = "inference" | "capabilities" | "runtime" | "app" -export type SettingsPanelId = - | "models" - | "skills" - | "connectors" - | "specialists" - | "compute" - | "network" - | "permissions" - | "sandbox" - | "credentials" - | "storage" - | "general" +// Source contract for every reachable Settings destination. Keep this list in +// rail order; the registry contract test verifies that no panel can be added, +// removed, or left without the shared layout audit silently. +export const SETTINGS_PANEL_IDS = [ + "models", + "skills", + "connectors", + "specialists", + "compute", + "network", + "permissions", + "sandbox", + "credentials", + "storage", + "general", +] as const + +export type SettingsPanelId = (typeof SETTINGS_PANEL_IDS)[number] export interface SettingsPanel { /** Stable key used for routing/history. */ @@ -45,7 +54,7 @@ export interface SettingsPanel { /** Which rail group the row lives under. */ section: SettingsSection /** Lazily-loaded panel body (default export of the file). */ - component: Component + component: Component & { preload?: () => Promise<unknown> } } // Order here is the render order in the rail (top→bottom within each section). @@ -62,7 +71,7 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ { id: "skills", title: "Skills", - icon: "brain", + icon: "flask", section: "capabilities", component: lazy(() => import("./Skills")), }, @@ -76,7 +85,7 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ { id: "specialists", title: "Specialists", - icon: "models", + icon: "brain", section: "capabilities", component: lazy(() => import("./Specialists")), }, @@ -86,28 +95,28 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ { id: "compute", title: "Compute", - icon: "server", + icon: "cpu", section: "runtime", component: lazy(() => import("./Compute")), }, { id: "network", title: "Network", - icon: "share", + icon: "server", section: "runtime", component: lazy(() => import("./Network")), }, { id: "permissions", title: "Permissions", - icon: "check", + icon: "shield", section: "runtime", component: lazy(() => import("./Permissions")), }, { id: "sandbox", title: "Sandbox", - icon: "console", + icon: "code", section: "runtime", component: lazy(() => import("./Sandbox")), }, @@ -125,7 +134,7 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ { id: "general", title: "General", - icon: "settings-gear", + icon: "sliders", section: "app", component: lazy(() => import("./General")), }, @@ -142,4 +151,8 @@ export function findPanel(id: SettingsPanelId): SettingsPanel { return SETTINGS_PANELS.find((p) => p.id === id) ?? SETTINGS_PANELS[0] } +export async function preloadPanel(id: SettingsPanelId): Promise<void> { + await findPanel(id).component.preload?.() +} + export const DEFAULT_PANEL: SettingsPanelId = "models" diff --git a/frontend/workspace/src/components/settings/sandbox.css b/frontend/workspace/src/components/settings/sandbox.css new file mode 100644 index 00000000..8f754ed2 --- /dev/null +++ b/frontend/workspace/src/components/settings/sandbox.css @@ -0,0 +1,293 @@ +/* Sandbox is deliberately quieter than the previous icon-per-row layout. + One semantic mark communicates backend availability; policy, path, and test + actions rely on proximity and a consistent 32px control rhythm. */ +.settings-preferences-panel--sandbox .settings-page-header__inner { + position: relative; +} + +.settings-preferences-panel--sandbox .settings-sandbox-save-state { + position: absolute; + top: 2px; + right: 0; + min-width: 48px; + color: var(--text-weak); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 18px; + text-align: right; +} + +.settings-preferences-panel--sandbox .settings-preferences-card { + overflow: hidden; +} + +.settings-preferences-panel--sandbox .settings-sandbox-control-row { + min-height: 56px; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: var(--settings-space-3); + padding: 10px var(--settings-space-4); +} + +.settings-preferences-panel--sandbox .settings-sandbox-control-row > :last-child { + max-width: 100%; + justify-self: end; +} + +.settings-preferences-panel--sandbox .settings-sandbox-status-row { + width: 100%; + min-height: 56px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) max-content 16px; + align-items: center; + gap: var(--settings-space-3); + padding: 10px var(--settings-space-4); + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-strong); + text-align: left; + transition: background-color 150ms ease; +} + +.settings-preferences-panel--sandbox .settings-sandbox-status-row:hover { + background: var(--settings-surface-hover); +} + +.settings-preferences-panel--sandbox .settings-sandbox-status-row:active { + background: var(--settings-selection); +} + +.settings-preferences-panel--sandbox .settings-sandbox-status-mark { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); + color: var(--text-warning-base); +} + +.settings-preferences-panel--sandbox .settings-sandbox-status-mark[data-tone="success"] { + background: color-mix(in srgb, var(--icon-success-base) 10%, transparent); + color: var(--icon-success-base); +} + +.settings-preferences-panel--sandbox .settings-sandbox-disclosure-icon { + color: var(--icon-weak-base); + transition: transform 200ms ease; +} + +.settings-preferences-panel--sandbox .settings-sandbox-disclosure-icon--open { + transform: rotate(180deg); +} + +.settings-preferences-panel--sandbox .settings-sandbox-backend-details { + padding: 0 var(--settings-space-4) var(--settings-space-4) 60px; +} + +.settings-preferences-panel--sandbox .settings-sandbox-backend-details dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--settings-space-3) var(--settings-space-4); + margin: 0; + padding: var(--settings-space-3); + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} + +.settings-preferences-panel--sandbox .settings-sandbox-backend-details dl > div { + min-width: 0; +} + +.settings-preferences-panel--sandbox .settings-sandbox-backend-details dt { + margin: 0 0 2px; + color: var(--text-weak); + font-size: 11px; + line-height: 16px; +} + +.settings-preferences-panel--sandbox .settings-sandbox-backend-details dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 11px; + line-height: 17px; + overflow-wrap: anywhere; + font-synthesis: none; + font-variant-numeric: slashed-zero; + text-wrap: pretty; +} + +.settings-preferences-panel--sandbox .settings-sandbox-path-row { + min-height: 48px; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: var(--settings-space-3); + padding: var(--settings-space-2) var(--settings-space-4); +} + +.settings-preferences-panel--sandbox .settings-sandbox-path-row code { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; + font-synthesis: none; + font-variant-numeric: slashed-zero; +} + +.settings-preferences-panel--sandbox .settings-sandbox-path-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: var(--settings-space-2); + padding: var(--settings-space-2) var(--settings-space-4) var(--settings-space-3); +} + +.settings-preferences-panel--sandbox .settings-sandbox-path-editor .settings-field { + min-width: 0; +} + +.settings-preferences-panel--sandbox .settings-sandbox-result { + padding: 0 var(--settings-space-4) var(--settings-space-4); +} + +.settings-preferences-panel--sandbox .settings-sandbox-result__summary { + min-height: 32px; + display: grid; + grid-template-columns: 8px minmax(0, 1fr) max-content; + align-items: center; + gap: var(--settings-space-2); + color: var(--text-strong); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 18px; +} + +.settings-preferences-panel--sandbox .settings-sandbox-result__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-danger); +} + +.settings-preferences-panel--sandbox .settings-sandbox-result__dot[data-tone="success"] { + background: var(--icon-success-base); +} + +.settings-preferences-panel--sandbox .settings-sandbox-checks { + display: grid; + gap: var(--settings-space-2); + margin-top: var(--settings-space-2); + padding: var(--settings-space-3); + border: 0; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} + +.settings-preferences-panel--sandbox .settings-sandbox-check { + display: grid; + grid-template-columns: 16px minmax(120px, auto) minmax(0, 1fr); + align-items: start; + gap: var(--settings-space-2); + color: var(--text-strong); + font-size: 12px; + line-height: 18px; +} + +.settings-preferences-panel--sandbox .settings-sandbox-check__detail { + min-width: 0; + color: var(--text-weak); + font-size: 11px; + line-height: 18px; + overflow-wrap: anywhere; + white-space: normal; + font-synthesis: none; + font-variant-numeric: slashed-zero; + text-wrap: pretty; +} + +.settings-preferences-panel--sandbox + :where(.settings-sandbox-status-row, .settings-preference-action, .settings-panel-action):focus-visible { + transition: none; +} + +@container settings-main (max-width: 620px) { + .settings-preferences-panel--sandbox .settings-sandbox-backend-details dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container settings-main (max-width: 520px) { + .settings-preferences-panel--sandbox .settings-sandbox-control-row:not(.settings-sandbox-enable-row) { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .settings-preferences-panel--sandbox .settings-sandbox-control-row:not(.settings-sandbox-enable-row) > :last-child { + justify-self: start; + } + + .settings-preferences-panel--sandbox .settings-sandbox-status-row { + grid-template-areas: + "mark copy disclosure" + ". state state"; + grid-template-columns: 32px minmax(0, 1fr) 16px; + } + + .settings-preferences-panel--sandbox .settings-sandbox-status-mark { + grid-area: mark; + } + + .settings-preferences-panel--sandbox .settings-sandbox-status-row .settings-row-copy { + grid-area: copy; + } + + .settings-preferences-panel--sandbox .settings-sandbox-status-row .settings-preference-status { + grid-area: state; + justify-self: start; + } + + .settings-preferences-panel--sandbox .settings-sandbox-disclosure-icon { + grid-area: disclosure; + } + + .settings-preferences-panel--sandbox .settings-sandbox-backend-details { + padding-left: var(--settings-space-4); + } + + .settings-preferences-panel--sandbox .settings-sandbox-result__summary { + grid-template-columns: 8px minmax(0, 1fr); + } + + .settings-preferences-panel--sandbox .settings-sandbox-result__summary .settings-preference-action { + grid-column: 2; + justify-self: start; + } + + .settings-preferences-panel--sandbox .settings-sandbox-check { + grid-template-columns: 16px minmax(0, 1fr); + } + + .settings-preferences-panel--sandbox .settings-sandbox-check__detail { + grid-column: 2; + } +} + +@container settings-main (max-width: 420px) { + .settings-preferences-panel--sandbox .settings-sandbox-backend-details dl, + .settings-preferences-panel--sandbox .settings-sandbox-path-editor { + grid-template-columns: minmax(0, 1fr); + } + + .settings-preferences-panel--sandbox .settings-sandbox-path-editor .settings-panel-action { + justify-self: start; + } +} diff --git a/frontend/workspace/src/components/settings/skills.css b/frontend/workspace/src/components/settings/skills.css new file mode 100644 index 00000000..240a43cb --- /dev/null +++ b/frontend/workspace/src/components/settings/skills.css @@ -0,0 +1,290 @@ +.settings-skills { + display: flex; + width: 100%; + max-width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: transparent; +} + +.settings-skills > .skills-workspace { + width: 100%; + max-width: 100%; + height: 100%; + font-optical-sizing: auto; +} + +.settings-skills .skills-workspace__header { + padding: var(--settings-space-6) var(--settings-space-7) var(--settings-space-4); + border-bottom: 0; + background: var(--settings-canvas); +} + +.settings-skills .skills-workspace__body { + padding: var(--settings-space-3) var(--settings-space-7) 64px; +} + +.settings-skills .skills-workspace__heading, +.settings-skills .skills-workspace__toolbar, +.settings-skills .skills-workspace__content { + width: 100%; + max-width: 900px; + margin-inline: auto; +} + +.settings-skills .skills-workspace__heading h2 { + font-size: var(--settings-type-title); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-title); + letter-spacing: -0.015em; +} + +.settings-skills .skills-workspace__heading p { + max-width: 700px; + margin-top: 4px; + font-size: var(--settings-type-body); + line-height: var(--settings-leading-body); +} + +.settings-skills .skills-workspace__summary { + color: var(--text-weak); + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-helper); +} + +/* Embedded Skills follows the same compact hierarchy as Compute and + Specialists. The standalone Skills workspace keeps its roomier catalog + scale in skills-page.css. */ +.settings-skills .skills-workspace__group-heading h3, +.settings-skills .skills-workspace__identity strong, +.settings-skills .skills-workspace__form-heading h3, +.settings-skills .skills-workspace__state > strong { + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); + letter-spacing: 0; +} + +.settings-skills .skills-workspace__group-heading > span, +.settings-skills .skills-workspace__identity-copy > span, +.settings-skills .skills-workspace__identity code, +.settings-skills .skills-workspace__details > p, +.settings-skills .skills-workspace__form-heading p, +.settings-skills .skills-workspace__form-fields > label > span, +.settings-skills .skills-workspace__form-fields input, +.settings-skills .skills-workspace__form-fields textarea, +.settings-skills .skills-workspace__security-note, +.settings-skills .skills-workspace__state > p, +.settings-skills .skills-workspace__state > button, +.settings-skills .skills-workspace__form-actions button { + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); +} + +/* The shared Settings toolbar is flex-based. Restore the catalog's denser + search/filter/action grid so it stays one calm toolbar at dialog widths. */ +.settings-skills .skills-workspace__toolbar-controls { + display: grid; + grid-template-columns: + minmax(180px, 1fr) minmax(148px, max-content) minmax(168px, max-content) + max-content; +} + +.settings-skills .skills-workspace .settings-control { + min-height: 32px; + height: 32px; + padding-inline: 10px; + border-color: transparent; + background: var(--settings-surface); + font-weight: var(--font-weight-regular); + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); +} + +.settings-skills .skills-workspace .settings-toolbar > label input { + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-regular); + line-height: var(--settings-leading-helper); +} + +.settings-skills .skills-workspace .settings-control:hover, +.settings-skills .skills-workspace .settings-control[data-expanded] { + border-color: transparent; + background: var(--settings-surface-active); +} + +.settings-skills .skills-workspace .settings-control:focus-within, +.settings-skills .skills-workspace .settings-control:focus-visible { + border-color: transparent; + outline: 1px solid var(--settings-border-strong); + outline-offset: 0; + box-shadow: none; +} + +.settings-skills .skills-workspace .settings-control[aria-label="Add skill"] { + background: var(--settings-surface-active); + color: var(--text-strong); +} + +.settings-skills .skills-workspace .settings-control[aria-label="Add skill"]:hover, +.settings-skills .skills-workspace .settings-control[aria-label="Add skill"][data-expanded] { + background: var(--settings-surface-hover); +} + +.settings-skills .skills-workspace .settings-control[aria-label="Add skill"] [data-component="icon"] { + color: currentColor; +} + +.settings-skills .skills-workspace__list { + gap: var(--settings-space-5); +} + +.settings-skills .skills-workspace__group { + gap: 8px; +} + +.settings-skills .skills-workspace__rows { + gap: 2px; + padding: 4px; + border: 0; + background: var(--settings-surface); + box-shadow: var(--settings-shadow-card); +} + +.settings-skills .skills-workspace__row { + border: 0; + border-radius: var(--settings-radius-control); + background: transparent; +} + +.settings-skills .skills-workspace__row:hover { + background: var(--settings-surface-hover); +} + +.settings-skills .skills-workspace__form-icon, +.settings-skills .skills-workspace__state-icon { + border: 0; +} + +.settings-skills .skills-workspace__form-icon, +.settings-skills .skills-workspace__state-icon, +.settings-skills .skills-workspace__tags .settings-chip { + background: var(--settings-surface-active); +} + +.settings-skills .skills-workspace__tags .settings-chip { + min-height: 20px; + padding: 1px 7px; + border: 0; + border-radius: var(--settings-radius-pill); + color: var(--text-strong); + font-size: var(--settings-type-helper); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-helper); +} + +.settings-skills .skills-workspace__form-fields { + border: 0; + background: var(--settings-surface-muted); +} + +.settings-skills .skills-workspace__form-fields .settings-field, +.settings-skills .skills-workspace__security-note { + border-color: transparent; + background: var(--settings-surface); +} + +.settings-skills .skills-workspace__form-fields input.settings-field { + min-height: 32px; + height: 32px; + padding-block: 5px; +} + +.settings-skills .skills-workspace__form-fields textarea.settings-field { + min-height: 88px; +} + +.settings-skills .skills-workspace__form-fields .settings-field:focus { + border-color: transparent; + outline: 1px solid var(--settings-border-strong); + outline-offset: 0; + box-shadow: none; +} + +.settings-skills .skills-workspace__state > .settings-button { + min-height: 32px; + padding-inline: 12px; + border: 0; + background: var(--settings-surface-muted); +} + +.settings-skills .skills-workspace [data-component="switch"][data-checked] [data-slot="switch-control"], +.settings-skills + .skills-workspace + [data-component="switch"][data-checked]:hover:not([data-disabled], [data-readonly]) + [data-slot="switch-control"] { + border-color: var(--settings-toggle-active); + background: var(--settings-toggle-active); +} + +.settings-skills .skills-workspace [data-component="switch"] [data-slot="switch-control"] { + width: 32px; + height: 18px; +} + +.settings-skills .skills-workspace [data-component="switch"] [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + margin-inline: 1px; + border-width: 0; +} + +.settings-skills .skills-workspace [data-component="switch"][data-checked] [data-slot="switch-thumb"] { + transform: translateX(14px); +} + +.settings-skills .skills-workspace__form-actions .settings-button { + min-height: 32px; + padding-inline: 12px; +} + +.settings-skills .skills-workspace__form-actions .settings-button[data-variant="primary"] { + border-color: var(--settings-primary); + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: none; +} + +@container skills-workspace (max-width: 960px) { + .settings-skills .skills-workspace__header { + padding: 24px 20px 16px; + } + + .settings-skills .skills-workspace__body { + padding: 12px 20px 32px; + } +} + +@container skills-workspace (max-width: 800px) { + .settings-skills .skills-workspace__toolbar-controls { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@container skills-workspace (max-width: 640px) { + /* Tags remain useful metadata in a resizable Settings pane. The base + workspace previously hid them at this width, which made the embedded + catalog look incomplete. */ + .settings-skills .skills-workspace__tags { + display: flex; + } +} + +@container skills-workspace (max-width: 460px) { + .settings-skills .skills-workspace__toolbar-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/frontend/workspace/src/components/settings/specialist-catalog.ts b/frontend/workspace/src/components/settings/specialist-catalog.ts index d3f5b6fd..ae3f57b7 100644 --- a/frontend/workspace/src/components/settings/specialist-catalog.ts +++ b/frontend/workspace/src/components/settings/specialist-catalog.ts @@ -2,6 +2,39 @@ import type { Agent } from "@synsci/sdk/v2/client" const SYSTEM_AGENTS = new Set(["title", "compaction"]) +export type SpecialistGroup = "research" | "review" | "workspace" + +export const SPECIALIST_GROUPS: ReadonlyArray<{ + id: SpecialistGroup + title: string + description: string +}> = [ + { + id: "research", + title: "Research", + description: "Core scientific roles for investigation, analysis, and synthesis.", + }, + { + id: "review", + title: "Review and writing", + description: "Independent critique, result checking, and scientific communication.", + }, + { + id: "workspace", + title: "Workspace support", + description: "Focused helpers for exploration and bounded delegated tasks.", + }, +] + +const REVIEW_SPECIALISTS = new Set(["critique", "physics-critique", "reviewer", "literature-review", "write"]) +const WORKSPACE_SPECIALISTS = new Set(["explore", "task"]) + +export function specialistGroupFor(agent: Pick<Agent, "name">): SpecialistGroup { + if (REVIEW_SPECIALISTS.has(agent.name)) return "review" + if (WORKSPACE_SPECIALISTS.has(agent.name)) return "workspace" + return "research" +} + export function isVisibleSpecialist(agent: Pick<Agent, "name" | "hidden">) { return !SYSTEM_AGENTS.has(agent.name) && agent.name !== "plan" && !agent.hidden } diff --git a/frontend/workspace/src/components/settings/specialists.css b/frontend/workspace/src/components/settings/specialists.css new file mode 100644 index 00000000..2281eeaf --- /dev/null +++ b/frontend/workspace/src/components/settings/specialists.css @@ -0,0 +1,427 @@ +.specialists-panel { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + container: specialists-panel / inline-size; +} + +.specialists-panel .settings-page-header, +.specialists-panel .settings-page-body { + --specialists-content-width: 900px; +} + +.specialists-panel .settings-page-header { + padding-bottom: var(--settings-space-3); + border-bottom: 0; +} + +.specialists-panel .settings-page-header__inner, +.specialists-panel .settings-page-body { + width: min(100%, var(--specialists-content-width)); + max-width: var(--specialists-content-width); +} + +.specialists-panel .settings-page-body { + gap: var(--settings-space-5); + padding-top: var(--settings-space-3); +} + +.specialists-panel .settings-toolbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(132px, max-content) max-content; + gap: var(--settings-space-2); +} + +.specialists-panel .settings-control { + min-height: 32px; + height: 32px; + padding-inline: 10px; + border-color: transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface); + font-size: 12px; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.specialists-panel .settings-control:hover, +.specialists-panel .settings-control[data-expanded] { + border-color: transparent; + background: var(--settings-surface-active); +} + +.specialists-panel .settings-control:focus-within, +.specialists-panel .settings-control:focus-visible { + border-color: transparent; + outline: 1px solid var(--settings-border-strong); + outline-offset: 0; + box-shadow: none; +} + +.specialists-panel .settings-control--primary { + border-color: transparent; + background: var(--settings-surface-active); + color: var(--text-strong); +} + +.specialists-panel .settings-control--primary:hover, +.specialists-panel .settings-control--primary[data-expanded] { + background: var(--settings-surface-hover); +} + +.specialists-panel__add { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; +} + +.specialists-panel .settings-section { + gap: var(--settings-space-3); +} + +.specialists-panel .settings-section-heading p { + max-width: 620px; + text-wrap: pretty; +} + +.specialists-panel .settings-card { + gap: 2px; + padding: 4px; + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface); + box-shadow: none; +} + +.specialists-catalog { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--settings-space-5); +} + +.specialists-group { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--settings-space-2); +} + +.specialists-group__header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: var(--settings-space-3); + padding-inline: 4px; +} + +.specialists-group__header > div { + min-width: 0; +} + +.specialists-group__header h4 { + margin: 0; + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); +} + +.specialists-group__header p { + max-width: 620px; + margin: 2px 0 0; + color: var(--text-weak); + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); + text-wrap: pretty; +} + +.specialists-group__header > span { + flex: 0 0 auto; + color: var(--text-weak); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: var(--settings-leading-body); +} + +.specialists-panel .settings-row.specialists-agent { + position: relative; + display: grid; + min-width: 0; + min-height: 60px; + grid-template-columns: 28px minmax(0, 1fr) max-content max-content; + align-items: center; + gap: var(--settings-space-3); + padding: 9px var(--settings-space-3); + border: 0; + border-radius: var(--settings-radius-control); + background: transparent; + transition: + background-color 150ms ease, + opacity 150ms ease; +} + +@media (hover: hover) { + .specialists-panel .settings-row.specialists-agent:hover { + background: var(--settings-surface-hover); + } +} + +.specialists-panel .settings-row.specialists-agent[data-saving="true"], +.specialists-panel .settings-row.specialists-agent[data-loading="true"] { + opacity: 0.78; +} + +.specialists-panel .settings-row.specialists-agent--reviewer { + grid-template-columns: minmax(0, 1fr) max-content; +} + +.specialists-agent__icon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--icon-weak-base); +} + +.specialists-agent__copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.specialists-agent__copy > strong { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + font-size: var(--settings-type-body); + font-weight: var(--font-weight-medium); + line-height: var(--settings-leading-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.specialists-agent__copy p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--text-weak); + font-size: var(--settings-type-helper); + line-height: var(--settings-leading-helper); + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.specialists-agent__mode { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 0; + border-radius: var(--settings-radius-pill); + background: var(--settings-surface-muted); + color: var(--text-weak); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 1; + white-space: nowrap; +} + +.specialists-agent__availability { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--settings-space-2); +} + +.specialists-agent__control { + min-width: 36px; + min-height: 32px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.specialists-agent__session-only { + color: var(--text-weak); + font-size: 11px; + white-space: nowrap; +} + +.specialists-panel .settings-row.specialists-agent > [data-component="icon-button"] { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; +} + +.specialists-panel [data-component="switch"] { + min-width: 36px; + min-height: 32px; + justify-content: flex-end; +} + +.specialists-panel [data-component="switch"] [data-slot="switch-control"] { + width: 32px; + height: 18px; + border-radius: var(--settings-radius-pill); +} + +.specialists-panel [data-component="switch"] [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + margin-inline: 1px; + border-width: 0; + border-radius: 50%; +} + +.specialists-panel [data-component="switch"][data-checked] [data-slot="switch-thumb"] { + transform: translateX(14px); +} + +.specialists-panel [data-component="switch"][data-checked] [data-slot="switch-control"], +.specialists-panel + [data-component="switch"][data-checked]:hover:not([data-disabled], [data-readonly]) + [data-slot="switch-control"] { + border-color: var(--settings-toggle-active); + background: var(--settings-toggle-active); +} + +.specialists-panel__form { + display: flex; + flex-direction: column; + gap: var(--settings-space-4); + padding: var(--settings-space-5); + border: 0; + border-radius: var(--settings-radius-card); + background: var(--settings-surface); +} + +.specialists-panel__form .settings-field--multiline { + min-height: 88px; +} + +.specialists-panel__field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.specialists-panel__field > span { + color: var(--text-strong); + font-size: 12px; + font-weight: var(--font-weight-medium); +} + +.specialists-panel__form .settings-field { + min-height: 32px; + padding-block: 5px; + border-color: transparent; + border-radius: var(--settings-radius-control); + background: var(--settings-surface-muted); +} + +.specialists-panel__form .settings-field:focus { + border-color: transparent; + outline: 1px solid var(--settings-border-strong); + outline-offset: 0; + box-shadow: none; +} + +.specialists-panel__form-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--settings-space-2); +} + +.specialists-panel__form-actions .settings-button { + min-height: 32px; + padding-inline: 12px; +} + +.specialists-panel__form-actions .settings-button[data-variant="primary"] { + border-color: var(--settings-primary); + background: var(--settings-primary); + color: var(--settings-on-primary); + box-shadow: none; +} + +@container specialists-panel (max-width: 700px) { + .specialists-panel .settings-toolbar { + grid-template-columns: 1fr max-content; + } + + .specialists-panel .settings-control--search { + grid-column: 1 / -1; + } +} + +@container specialists-panel (max-width: 360px) { + .specialists-panel .settings-row.specialists-agent { + grid-template-columns: 28px minmax(0, 1fr) max-content; + row-gap: 4px; + } + + .specialists-panel .settings-row.specialists-agent--reviewer { + grid-template-columns: minmax(0, 1fr); + } + + .specialists-agent__availability { + grid-column: 2 / -1; + grid-row: 2; + justify-content: flex-start; + } + + .specialists-agent--reviewer .specialists-agent__availability { + grid-column: 1; + } + + .specialists-panel .settings-row.specialists-agent > [data-component="icon-button"] { + grid-column: 3; + grid-row: 1; + } +} + +@container specialists-panel (max-width: 520px) { + .specialists-panel .settings-toolbar { + grid-template-columns: 1fr; + } + + .specialists-panel .settings-control--primary { + width: 100%; + justify-content: center; + } +} + +@media (pointer: coarse) { + .specialists-panel .settings-control, + .specialists-panel [data-component="switch"], + .specialists-panel .settings-row.specialists-agent > [data-component="icon-button"] { + min-height: 44px; + } + + .specialists-panel .settings-row.specialists-agent > [data-component="icon-button"] { + width: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .specialists-panel .settings-control, + .specialists-panel .settings-row.specialists-agent { + transition: none; + } +} diff --git a/frontend/workspace/src/components/settings/startup-update.test.ts b/frontend/workspace/src/components/settings/startup-update.test.ts new file mode 100644 index 00000000..3232a395 --- /dev/null +++ b/frontend/workspace/src/components/settings/startup-update.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { queueStartupUpdateCheck } from "./startup-update" + +describe("startup update preference", () => { + test("does not schedule a network request when startup checks are disabled", () => { + let scheduled = 0 + let checked = 0 + queueStartupUpdateCheck({ + enabled: false, + check: async () => { + checked++ + return { updateAvailable: true } + }, + notify: () => {}, + schedule: (() => { + scheduled++ + return 1 as unknown as ReturnType<typeof setTimeout> + }) as (run: () => void, delay: number) => ReturnType<typeof setTimeout>, + }) + + expect(scheduled).toBe(0) + expect(checked).toBe(0) + }) + + test("defers the real check and only announces an available update", async () => { + const queued: Array<() => void> = [] + const notices: string[] = [] + let checked = 0 + queueStartupUpdateCheck({ + enabled: true, + check: async () => { + checked++ + return { updateAvailable: true, version: "2.1.0" } + }, + notify: (result) => notices.push(result.version ?? "missing"), + schedule: ((run) => { + queued.push(run) + return 1 as unknown as ReturnType<typeof setTimeout> + }) as (run: () => void, delay: number) => ReturnType<typeof setTimeout>, + }) + + expect(checked).toBe(0) + expect(queued).toHaveLength(1) + queued[0]!() + await Promise.resolve() + await Promise.resolve() + + expect(checked).toBe(1) + expect(notices).toEqual(["2.1.0"]) + }) + + test("cancellation prevents a queued check after the app unmounts", () => { + let run: (() => void) | undefined + let checked = 0 + let cancelled = 0 + const stop = queueStartupUpdateCheck({ + enabled: true, + check: async () => { + checked++ + return { updateAvailable: false } + }, + notify: () => {}, + schedule: ((next) => { + run = next + return 7 as unknown as ReturnType<typeof setTimeout> + }) as (run: () => void, delay: number) => ReturnType<typeof setTimeout>, + cancel: () => cancelled++, + }) + + stop() + run?.() + + expect(cancelled).toBe(1) + expect(checked).toBe(0) + }) + + test("a failed background check is contained and never notifies", async () => { + let run: (() => void) | undefined + let notices = 0 + queueStartupUpdateCheck({ + enabled: true, + check: async () => { + throw new Error("registry unavailable") + }, + notify: () => notices++, + schedule: ((next) => { + run = next + return 1 as unknown as ReturnType<typeof setTimeout> + }) as (run: () => void, delay: number) => ReturnType<typeof setTimeout>, + }) + + run?.() + await Promise.resolve() + await Promise.resolve() + + expect(notices).toBe(0) + }) +}) diff --git a/frontend/workspace/src/components/settings/startup-update.tsx b/frontend/workspace/src/components/settings/startup-update.tsx new file mode 100644 index 00000000..e4e828f3 --- /dev/null +++ b/frontend/workspace/src/components/settings/startup-update.tsx @@ -0,0 +1,83 @@ +import { createEffect, onCleanup, type Component } from "solid-js" +import { showToast } from "@synsci/ui/toast" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { URLS } from "@/config/urls" + +type UpdateResult = { updateAvailable: boolean; version?: string } + +export function queueStartupUpdateCheck(input: { + enabled: boolean + check?: () => Promise<UpdateResult> + notify: (result: UpdateResult) => void + delayMs?: number + schedule?: (run: () => void, delayMs: number) => ReturnType<typeof setTimeout> + cancel?: (handle: ReturnType<typeof setTimeout>) => void +}): () => void { + if (!input.enabled || !input.check) return () => {} + + let active = true + const schedule = input.schedule ?? ((run, delay) => setTimeout(run, delay)) + const cancel = input.cancel ?? clearTimeout + const handle = schedule(() => { + if (!active) return + void input.check!() + .then((result) => { + if (active && result.updateAvailable) input.notify(result) + }) + // A background update check must never turn a healthy launch into an + // error surface. Manual "Check now" still reports failures explicitly. + .catch(() => undefined) + }, input.delayMs ?? 1_500) + + return () => { + active = false + cancel(handle) + } +} + +/** + * Runs once per application launch, after persisted Settings have loaded and + * outside the first-paint path. This is the real consumer for the General → + * "Check for updates on startup" preference. + */ +export const StartupUpdateCheck: Component = () => { + const platform = usePlatform() + const settings = useSettings() + const language = useLanguage() + let queued = false + let cancel = () => {} + + createEffect(() => { + if (queued || !settings.ready()) return + queued = true + cancel = queueStartupUpdateCheck({ + enabled: settings.updates.startup(), + check: platform.checkUpdate, + notify: (result) => { + showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: result.version ?? "" }), + actions: [ + { + label: language.t("settings.general.row.releaseNotes.title"), + onClick: () => platform.openLink(URLS.releases), + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss", + }, + ], + }) + }, + }) + }) + + onCleanup(() => cancel()) + return null +} + +export default StartupUpdateCheck diff --git a/frontend/workspace/src/components/settings/theme-lock.test.ts b/frontend/workspace/src/components/settings/theme-lock.test.ts new file mode 100644 index 00000000..376454c0 --- /dev/null +++ b/frontend/workspace/src/components/settings/theme-lock.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" + +const app = await Bun.file(new URL("../../app.tsx", import.meta.url)).text() +const general = await Bun.file(new URL("../settings-general.tsx", import.meta.url)).text() +const preload = await Bun.file(new URL("../../../public/openscience-theme-preload.js", import.meta.url)).text() +const theme = await Bun.file(new URL("../../../../ui/src/theme/context.tsx", import.meta.url)).text() +const index = await Bun.file(new URL("../../../index.html", import.meta.url)).text() + +describe("canonical OpenScience theme", () => { + test("pins the product palette while preserving the selected display mode", () => { + expect(app).toContain('<ThemeProvider lockedTheme="openscience">') + expect(app).not.toContain('lockedScheme="light"') + expect(preload).toContain('var themeId = "openscience"') + expect(preload).toContain('"openscience-theme-css-" + themeId + "-" + mode') + expect(preload).not.toContain('localStorage.getItem("openscience-theme-id")') + expect(preload).toContain('localStorage.getItem("openscience-color-scheme") || "dark"') + expect(index).toContain('<meta name="theme-color" content="#26241f" />') + expect(preload).toContain('scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches') + expect(preload).not.toContain('localStorage.setItem("openscience-color-scheme", mode)') + expect(preload).toContain("css.match(/--background-base:") + expect(theme).toContain("lockedTheme?: string") + expect(theme).toContain('lockedScheme?: Exclude<ColorScheme, "system">') + expect(theme).toContain('const initialScheme = lockedScheme ?? getStoredColorScheme() ?? "dark"') + expect(theme).toContain('mode: initialScheme === "system" ? getSystemMode() : initialScheme') + expect(theme).toContain("querySelector<HTMLMetaElement>('meta[name=\"theme-color\"]')") + expect(theme).toContain("localStorage.removeItem(STORAGE_KEYS.LEGACY_THEME_CSS_LIGHT)") + expect(theme).toContain("if (lockedTheme && id !== lockedTheme) return") + expect(theme).toContain("if (lockedScheme && scheme !== lockedScheme) return") + }) + + test("offers a compact mode control without restoring theme selection", () => { + expect(general).toContain("theme.setColorScheme(option.value)") + expect(general).toContain('role="group"') + expect(general).toContain("aria-pressed={theme.colorScheme() === option.value}") + expect(general).toContain('class="h-8 min-w-[56px]') + expect(general).toContain("duration-150") + expect(general).not.toContain("themeSwatches") + expect(general).not.toContain("theme.setTheme(") + expect(general).not.toContain("settings.general.row.theme.title") + }) +}) diff --git a/frontend/workspace/src/components/settings/truth-pass.test.ts b/frontend/workspace/src/components/settings/truth-pass.test.ts index a9e4a985..743e693b 100644 --- a/frontend/workspace/src/components/settings/truth-pass.test.ts +++ b/frontend/workspace/src/components/settings/truth-pass.test.ts @@ -40,7 +40,7 @@ describe("launch settings truth pass", () => { expect(ids.indexOf("skills")).toBe(ids.indexOf("models") + 1) expect(panel.title).toBe("Skills") expect(panel.section).toBe("capabilities") - expect(panel.icon).toBe("brain") + expect(panel.icon).toBe("flask") expect(source("Skills.tsx")).toContain("<SkillsPage embedded />") }) @@ -51,7 +51,7 @@ describe("launch settings truth pass", () => { expect(SETTINGS_PANELS.find((item) => item.id === "general")?.section).toBe("app") }) - test("keeps local and SSH compute independent from deferred Atlas targets", () => { + test("keeps local Python, R, shell, Modal, and SSH compute operational", () => { const compute = source("Compute.tsx") expect(compute).not.toContain("Model endpoints") @@ -59,12 +59,19 @@ describe("launch settings truth pass", () => { expect(compute).not.toContain("GPU providers") expect(compute).toContain("call<Info>()") expect(compute).toContain('call<Info>("/ssh"') - expect(compute).toContain('title="Local machine"') - expect(compute).toContain('title="Remote compute"') - expect(compute).toContain("Connect directly over SSH. Atlas is not required.") + expect(compute).toContain('title="Local runtimes"') + expect(compute).toContain('title="Python and R kernels"') + expect(compute).toContain('title="Shell and local jobs"') + expect(compute).toContain("Session-owned kernels preserve in-memory state") + expect(compute).toContain('title="Remote hosts"') + expect(compute).toContain("Ready to dispatch") + expect(compute).toContain("Pin a host key, then dispatch staged jobs") + expect(compute).not.toContain("not execution targets") + expect(compute).not.toContain("Remote job dispatch remains unavailable") expect(compute).toContain('title="Cloud credentials"') expect(compute).not.toContain('title="Atlas Compute"') - expect(compute).not.toContain("coming later") + expect(compute).not.toContain("Coming soon") + expect(compute).not.toContain("/atlas-compute") }) test("prefers an active Modal CLI profile without exposing its credentials", () => { @@ -94,19 +101,38 @@ describe("launch settings truth pass", () => { expect(compute).toContain('aria-live="polite"') }) - test("keeps deferred cloud storage out of Storage", () => { + test("exposes verified live storage relocation without restart copy", () => { const storage = source("Storage.tsx") expect(storage).not.toContain("Cloud storage") expect(storage).not.toContain("manage cloud credentials") expect(storage).not.toContain("window.prompt") - expect(storage).toContain('aria-label="New data directory"') - expect(storage).toContain("Copy data") - expect(storage).toContain("Reset location") + expect(storage).not.toContain("Copy data") + expect(storage).not.toContain("restart") + expect(storage).toContain("Change location") + expect(storage).toContain("Move data") + expect(storage).toContain('method: "DELETE"') + expect(storage).toContain("switches every running server") + }) + + test("states the effective grant-only sandbox boundary", () => { + const sandbox = source("Sandbox.tsx") + + expect(sandbox).toContain('readIsolation?: "grant_only" | "unavailable"') + expect(sandbox).toContain('networkIsolation?: "deny_all" | "unavailable"') + expect(sandbox).toContain('capability === "grant_only" || (capability === undefined && nativeBackendActive())') + expect(sandbox).toContain('capability === "deny_all" || (capability === undefined && nativeBackendActive())') + expect(sandbox).toContain("Reads and writes are limited to the workspace and approved paths") + expect(sandbox).toContain("This backend always denies network access") + expect(sandbox).toContain("including loopback, LAN, link-local, and metadata endpoints") + expect(sandbox).not.toContain("private-network addresses may still be reachable") + expect(sandbox).not.toContain("host_readable") + expect(sandbox).not.toContain("does not isolate host reads") }) test("connectors persist enablement and inspect real server capabilities", () => { const connectors = source("Connectors.tsx") + const connectorForm = source("connector-form.ts") expect(connectors).toContain("sdk.client.mcp.inspect({ name })") expect(connectors).toContain('sdk.client.mcp.config.set({ name, config: next, scope: "global" })') @@ -115,12 +141,12 @@ describe("launch settings truth pass", () => { expect(connectors).toContain("saved, but could not connect") expect(connectors).toContain("<ConnectorInspection detail={detail()} />") expect(connectors).toContain("Stored header values are masked") - expect(connectors).toContain("restoreRecord") - expect(connectors).toContain("Add remote server") - expect(connectors).toContain("Add local command") + expect(connectorForm).toContain("restoreRecord") + expect(connectors).toContain('label="Hosted server"') + expect(connectors).toContain('label="Local process"') expect(connectors).toContain('label="Add connector"') - expect(connectors).toContain('label: "Remote URL"') - expect(connectors).toContain('label: "Local command"') + expect(connectors).toContain('label: "Hosted server"') + expect(connectors).toContain('label: "Local process"') expect(connectors).toContain('"Save connector"') expect(connectors).toContain('label="Cancel"') expect(connectors).toContain("Refresh status") @@ -151,6 +177,16 @@ describe("launch settings truth pass", () => { expect(credentials).not.toContain("Provider keys") }) + test("describes stored credentials without claiming an untested service connection", () => { + const services = source("CredentialServices.tsx") + + expect(services).toContain("Encrypted on this machine") + expect(services).toContain("{count()} saved") + expect(services).toContain('<span class="settings-chip">Saved</span>') + expect(services).not.toContain("connected={service.connected}") + expect(services).not.toContain("Connected and ready") + }) + test("presents the four built-in specialists with product-facing names", () => { const specialists = source("Specialists.tsx") diff --git a/frontend/workspace/src/components/terminal-error.test.ts b/frontend/workspace/src/components/terminal-error.test.ts index 713fa224..36133216 100644 --- a/frontend/workspace/src/components/terminal-error.test.ts +++ b/frontend/workspace/src/components/terminal-error.test.ts @@ -10,11 +10,11 @@ describe("connectionError", () => { test("wraps the bare error Event WebKit fires when a socket drops", () => { const wrapped = connectionError(new Event("error")) expect(wrapped).toBeInstanceOf(Error) - expect(wrapped.message).toBe("connection to the server was lost") + expect(wrapped.message).toBe("Connection to the server was lost.") }) test("wraps any other non-Error rejection", () => { - expect(connectionError(undefined).message).toBe("connection to the server was lost") - expect(connectionError("boom").message).toBe("connection to the server was lost") + expect(connectionError(undefined).message).toBe("Connection to the server was lost.") + expect(connectionError("boom").message).toBe("Connection to the server was lost.") }) }) diff --git a/frontend/workspace/src/components/terminal-error.ts b/frontend/workspace/src/components/terminal-error.ts index 3efb6a29..7091a280 100644 --- a/frontend/workspace/src/components/terminal-error.ts +++ b/frontend/workspace/src/components/terminal-error.ts @@ -3,4 +3,4 @@ // that Event put "[object Event]" in the disconnect toast — normalize anything // non-Error into a readable fallback instead. export const connectionError = (cause: unknown) => - cause instanceof Error ? cause : new Error("connection to the server was lost") + cause instanceof Error ? cause : new Error("Connection to the server was lost.") diff --git a/frontend/workspace/src/components/terminal-surface.test.ts b/frontend/workspace/src/components/terminal-surface.test.ts index b303ce2d..932b59e1 100644 --- a/frontend/workspace/src/components/terminal-surface.test.ts +++ b/frontend/workspace/src/components/terminal-surface.test.ts @@ -36,6 +36,10 @@ describe("contextual project terminal", () => { expect(surface).toContain('role="tabpanel"') expect(surface).toContain("active={active()?.id === pty.id}") expect(surface).toContain("terminal.close(pty.id)") + expect(surface).toContain(".clone(id)") + expect(surface).toContain('class="terminal-surface__error"') + expect(surface).toContain("Try again") + expect(surface).toContain('import "@/atlas/TerminalSurface.css"') expect(surface).not.toContain('aria-label="Copy terminal selection"') expect(surface).not.toContain('aria-label="Copy all output"') expect(surface).not.toContain('aria-label="Find in terminal"') @@ -57,8 +61,15 @@ describe("contextual project terminal", () => { expect(session).toContain('keybind: "ctrl+`"') expect(session).toContain('id: "terminal.new"') expect(session).toContain('keybind: "ctrl+shift+`"') - expect(context).toContain("load(sdk.scope, params.id)") - expect(context).toContain("sdk.client.pty") + expect(context).toContain("createMemo(() => load(sdk.scope))") + expect(context).not.toContain("load(sdk.scope, params.id)") + expect(context).toContain("const session = currentSession()") + expect(context).toContain("const client = sdk.client") + expect(context).toContain("client.pty.remove({ ptyID: pty.id })") + expect(context).not.toContain("sdk.client.pty") + expect(context).toContain("const owner = (id: string)") + expect(session).toContain('if (context !== "terminal") return') + expect(session).toContain("void ensureSession()") expect(terminal).toContain("sdk.request.url(`/pty/${local.pty.id}/connect`)") expect(terminal).toContain("onOpenSearch") expect(terminal).toContain("export const preloadTerminal") @@ -66,6 +77,19 @@ describe("contextual project terminal", () => { expect(terminal).toContain("t.selectAll()") }) + test("keeps terminal styling local, semantic, and deliberately light", async () => { + const [css, globalCss] = await Promise.all([read("../atlas/TerminalSurface.css"), read("../styles/atlas.css")]) + + expect(css).toContain(".terminal-surface__error button") + expect(css).toContain("font-weight: var(--font-weight-medium)") + expect(css).toContain("background: var(--color-bg)") + expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}/) + expect(globalCss).not.toContain(".terminal-surface") + for (const selector of ["terminal-surface", "terminal-surface__search", "terminal-surface__tabs-row"]) { + expect(css.match(new RegExp(`^\\.${selector} \\{`, "gm"))).toHaveLength(1) + } + }) + test("keeps existing terminals closable while authority only gates new process creation", async () => { const surface = await read("../atlas/TerminalSurface.tsx") diff --git a/frontend/workspace/src/context/command.tsx b/frontend/workspace/src/context/command.tsx index 8a09c606..3f183bc6 100644 --- a/frontend/workspace/src/context/command.tsx +++ b/frontend/workspace/src/context/command.tsx @@ -8,8 +8,6 @@ import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) -const PALETTE_ID = "command.palette" -const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." function actionId(id: string) { @@ -234,12 +232,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const suspended = () => store.suspendCount > 0 - const palette = createMemo(() => { - const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND - const keybinds = parseKeybind(config) - return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt))) - }) - const keymap = createMemo(() => { const map = new Map<string, CommandOption>() for (const option of options()) { @@ -267,21 +259,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } } - const showPalette = () => { - run("file.open", "palette") - } - const handleKeyDown = (event: KeyboardEvent) => { if (suspended() || dialog.active) return const sig = signatureFromEvent(event) - - if (palette().has(sig)) { - event.preventDefault() - showPalette() - return - } - const option = keymap().get(sig) if (!option) return event.preventDefault() @@ -308,10 +289,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex run(id, source) }, keybind(id: string) { - if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) - } - const base = actionId(id) const option = options().find((x) => actionId(x.id) === base) if (option?.keybind) return formatKeybind(option.keybind) @@ -321,7 +298,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (!config) return "" return formatKeybind(config) }, - show: showPalette, keybinds(enabled: boolean) { setStore("suspendCount", (count) => count + (enabled ? -1 : 1)) }, diff --git a/frontend/workspace/src/context/file.tsx b/frontend/workspace/src/context/file.tsx index cff14e8e..7a15d0ea 100644 --- a/frontend/workspace/src/context/file.tsx +++ b/frontend/workspace/src/context/file.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { createEffect, createMemo, createRoot, onCleanup, untrack } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@synsci/ui/context" import type { FileContent, FileNode } from "@synsci/sdk/v2" @@ -184,18 +184,18 @@ function touchContent(path: string, bytes?: number) { contentLru.set(path, value) } -type ViewSession = ReturnType<typeof createViewSession> +type ViewSession = ReturnType<typeof createProjectView> type ViewCacheEntry = { value: ViewSession dispose: VoidFunction } -function createViewSession(dir: string, id: string | undefined) { - const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` +function createProjectView(dir: string, legacySession: string | undefined) { + const legacyViewKey = `${dir}/file${legacySession ? "/" + legacySession : ""}.v1` const [view, setView, _, ready] = persisted( - Persist.scoped(dir, id, "file-view", [legacyViewKey]), + Persist.workspace(dir, "file-view", [legacyViewKey, `${dir}/file.v1`]), createStore<{ file: Record<string, FileViewState> }>({ @@ -418,8 +418,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } } - const loadView = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` + const loadView = (dir: string) => { + const key = dir || WORKSPACE_KEY const existing = viewCache.get(key) if (existing) { viewCache.delete(key) @@ -428,7 +428,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const entry = createRoot((dispose) => ({ - value: createViewSession(dir, id), + value: createProjectView( + dir, + untrack(() => params.id), + ), dispose, })) @@ -437,7 +440,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return entry.value } - const view = createMemo(() => loadView(storage(), params.id)) + // File scroll, selection, and preview state belong to the project-owned + // inspector. Session changes update mutation authority at call time but do + // not replace the visible file surface. + const view = createMemo(() => loadView(storage())) function ensure(path: string) { if (!path) return diff --git a/frontend/workspace/src/context/models.tsx b/frontend/workspace/src/context/models.tsx index 0953a2f3..1974f173 100644 --- a/frontend/workspace/src/context/models.tsx +++ b/frontend/workspace/src/context/models.tsx @@ -145,8 +145,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( setStore("recent", uniq) } - // New installations start unpinned. The composer independently presents a - // small recommended trio, so pinning is always an explicit user choice. + // New installations start unpinned. The composer derives its suggested set + // from available models, so pinning is always an explicit user choice. const pinned = createMemo(() => (store.pinned ?? []).slice(0, 3)) const isPinned = (model: ModelKey) => { const key = canonicalKey(model.providerID, model.modelID) diff --git a/frontend/workspace/src/context/notification.tsx b/frontend/workspace/src/context/notification.tsx index 85aa9ef6..63f9c077 100644 --- a/frontend/workspace/src/context/notification.tsx +++ b/frontend/workspace/src/context/notification.tsx @@ -10,7 +10,7 @@ import { useSettings } from "@/context/settings" import { Binary } from "@synsci/util/binary" import { EventSessionError } from "@synsci/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSound, preloadSound, soundSrc } from "@/utils/sound" import { projectForDirectory, projectHref, resolveProjectRoute } from "@/utils/project-route" type NotificationBase = { @@ -86,6 +86,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi setStore("list", pruneNotifications(store.list)) }) + createEffect(() => { + if (!settings.sounds.enabled()) return + preloadSound(soundSrc(settings.sounds.agent())) + preloadSound(soundSrc(settings.sounds.errors())) + }) + const append = (notification: Notification) => { setStore("list", (list) => pruneNotifications([...list, notification])) } @@ -158,7 +164,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const session = match.found ? syncStore.session[match.index] : undefined if (session?.parentID) break - playSound(soundSrc(settings.sounds.agent())) + if (settings.sounds.enabled()) { + playSound(soundSrc(settings.sounds.agent()), settings.sounds.volume()) + } append({ directory, @@ -188,7 +196,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const error = "error" in event.properties ? event.properties.error : undefined if (isTransientError(error)) break - playSound(soundSrc(settings.sounds.errors())) + if (settings.sounds.enabled()) { + playSound(soundSrc(settings.sounds.errors()), settings.sounds.volume()) + } append({ directory, diff --git a/frontend/workspace/src/context/permission.tsx b/frontend/workspace/src/context/permission.tsx index c74fcc7b..6dbb11e1 100644 --- a/frontend/workspace/src/context/permission.tsx +++ b/frontend/workspace/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@synsci/ui/context" import type { PermissionRequest } from "@synsci/sdk/v2/client" @@ -7,7 +7,11 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" import { base64Encode } from "@synsci/util/encode" -import { projectScope, resolveProjectRoute } from "@/utils/project-route" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { playSound, preloadSound, soundSrc } from "@/utils/sound" +import { projectForDirectory, projectHref, projectScope, resolveProjectRoute } from "@/utils/project-route" type PermissionRespondFn = (input: { sessionID: string @@ -52,6 +56,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const params = useParams() const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() + const language = useLanguage() + const platform = usePlatform() + const settings = useSettings() const permissionsEnabled = createMemo(() => { const route = resolveProjectRoute(params.dir, globalSync.data.project) @@ -71,6 +78,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map<string, number>() + createEffect(() => { + if (!settings.sounds.enabled()) return + preloadSound(soundSrc(settings.sounds.permissions())) + }) + function pruneResponded(now: number) { for (const [id, ts] of responded) { if (now - ts < RESPONDED_TTL_MS) break @@ -120,10 +132,30 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple if (event?.type !== "permission.asked") return const perm = event.properties - if (!isAutoAccepting(perm.sessionID, e.name)) return - if (!shouldAutoAccept(perm)) return + if (isAutoAccepting(perm.sessionID, e.name) && shouldAutoAccept(perm)) { + respondOnce(perm, e.name) + return + } - respondOnce(perm, e.name) + if (settings.sounds.enabled()) { + playSound(soundSrc(settings.sounds.permissions()), settings.sounds.volume()) + } + + if (settings.notifications.permissions()) { + const project = projectForDirectory(globalSync.data.project, e.name) + const [syncStore] = globalSync.child(e.name, { bootstrap: false }) + const session = syncStore.session.find((item) => item.id === perm.sessionID) + const projectName = + ((project as { name?: string } | undefined)?.name || e.name.split(/[\\/]/).filter(Boolean).at(-1)) ?? + project?.id ?? + "OpenScience" + const description = language.t("notification.permission.description", { + sessionTitle: session?.title ?? perm.sessionID, + projectName, + }) + const href = project ? projectHref(project, e.name, perm.sessionID) : "/" + void platform.notify(language.t("notification.permission.title"), description, href) + } }) onCleanup(unsubscribe) diff --git a/frontend/workspace/src/context/platform.tsx b/frontend/workspace/src/context/platform.tsx index 0df50091..06c783f8 100644 --- a/frontend/workspace/src/context/platform.tsx +++ b/frontend/workspace/src/context/platform.tsx @@ -39,7 +39,7 @@ export type Platform = { storage?: (name?: string) => SyncStorage | AsyncStorage /** Check for updates (Tauri only) */ - checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }> + checkUpdate?(options?: { refresh?: boolean }): Promise<{ updateAvailable: boolean; version?: string }> /** Install updates (Tauri only) */ update?(): Promise<void> diff --git a/frontend/workspace/src/context/settings.tsx b/frontend/workspace/src/context/settings.tsx index 791daab4..62219179 100644 --- a/frontend/workspace/src/context/settings.tsx +++ b/frontend/workspace/src/context/settings.tsx @@ -10,6 +10,8 @@ export interface NotificationSettings { } export interface SoundSettings { + enabled: boolean + volume: number agent: string permissions: string errors: string @@ -60,12 +62,13 @@ const defaultSettings: Settings = { errors: false, }, sounds: { - // Silent by default — UI sounds are opt-in. ("" → soundSrc() returns - // undefined → playSound() is a no-op.) The agent/error sound firing on - // click-triggered notifications was jarring; enable per-sound in Settings. - agent: "", - permissions: "", - errors: "", + // Audio feedback is opt-in. The choices are ready when it is enabled, but + // fresh installs remain silent and playback stays deliberately subtle. + enabled: false, + volume: 0.3, + agent: "yup-01", + permissions: "bip-bop-01", + errors: "alert-01", }, ui: { showChangesView: false, @@ -169,6 +172,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, sounds: { + enabled: createMemo(() => { + const stored = store.sounds?.enabled + if (stored !== undefined) return stored + // Preserve the intent of pre-toggle settings: a previously chosen + // sound means the user had explicitly enabled audio feedback. + return Boolean(store.sounds?.agent || store.sounds?.permissions || store.sounds?.errors) + }), + setEnabled(value: boolean) { + setStore("sounds", "enabled", value) + if (!value) return + if (!store.sounds.agent) setStore("sounds", "agent", defaultSettings.sounds.agent) + if (!store.sounds.permissions) setStore("sounds", "permissions", defaultSettings.sounds.permissions) + if (!store.sounds.errors) setStore("sounds", "errors", defaultSettings.sounds.errors) + }, + volume: createMemo(() => store.sounds?.volume ?? defaultSettings.sounds.volume), + setVolume(value: number) { + const next = Number.isFinite(value) ? value : defaultSettings.sounds.volume + setStore("sounds", "volume", Math.min(1, Math.max(0, next))) + }, agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent), setAgent(value: string) { setStore("sounds", "agent", value) diff --git a/frontend/workspace/src/context/terminal.tsx b/frontend/workspace/src/context/terminal.tsx index 94bf35fe..5737d0a0 100644 --- a/frontend/workspace/src/context/terminal.tsx +++ b/frontend/workspace/src/context/terminal.tsx @@ -1,6 +1,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@synsci/ui/context" -import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createRoot, createSignal, onCleanup, untrack } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import { Persist, persisted } from "@/utils/persist" @@ -9,23 +9,34 @@ export type LocalPTY = { id: string title: string titleNumber: number + /** Session whose execution authority created this project terminal. */ + sessionID?: string rows?: number cols?: number buffer?: string scrollY?: number } -const MAX_TERMINAL_SESSIONS = 20 +const MAX_TERMINAL_PROJECTS = 20 -type TerminalSession = ReturnType<typeof createTerminalSession> +type TerminalSession = ReturnType<typeof createProjectTerminalSession> type TerminalCacheEntry = { value: TerminalSession dispose: VoidFunction } -function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) { - const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] +function createProjectTerminalSession( + sdk: ReturnType<typeof useSDK>, + dir: string, + currentSession: () => string | undefined, + legacySession?: string, +) { + // Capture the client for this project cache entry. The SDK provider itself + // is reactive, so reading `sdk.client` later from an old entry could send a + // cleanup for project A through project B after navigation. + const client = sdk.client + const legacy = legacySession ? [`${dir}/terminal/${legacySession}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] const numberFromTitle = (title: string) => { const match = title.match(/^Terminal (\d+)$/) @@ -35,8 +46,8 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess return value } - const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, session, "terminal", legacy), + const [store, setStore, _, persistenceReady] = persisted( + Persist.workspace(dir, "terminal", legacy), createStore<{ active?: string all: LocalPTY[] @@ -45,8 +56,54 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess }), ) - const unsub = sdk.event.on("pty.exited", (event) => { - const id = event.properties.id + // The backend PTY list is the authority for process identity. Local + // persistence carries presentation state (active tab, terminal dimensions, + // buffered text) across reloads, then this reconciliation drops processes + // that no longer exist and adopts live ones without manufacturing new IDs. + const [hydrated, setHydrated] = createSignal(false) + let hydration: Promise<void> | undefined + + const refresh = () => { + if (!persistenceReady()) return Promise.resolve() + if (hydration) return hydration + setHydrated(false) + hydration = client.pty + .list() + .then((response) => { + const remote = (response.data ?? []).filter((pty) => pty.status !== "exited") + const local = new Map(store.all.map((pty) => [pty.id, pty])) + const next = remote.map((pty, index) => { + const remembered = local.get(pty.id) + return { + ...remembered, + id: pty.id, + title: pty.title, + titleNumber: remembered?.titleNumber ?? numberFromTitle(pty.title) ?? index + 1, + sessionID: pty.sessionID, + } satisfies LocalPTY + }) + batch(() => { + setStore("all", next) + if (!next.some((pty) => pty.id === store.active)) setStore("active", next[0]?.id) + }) + }) + .catch(() => { + // Keep the persisted project terminals visible while the local server + // is unavailable. Their sockets surface a precise reconnect state. + }) + .finally(() => { + hydration = undefined + setHydrated(true) + }) + return hydration + } + + createEffect(() => { + if (!persistenceReady()) return + void refresh() + }) + + const removeExited = (id: string) => { if (!store.all.some((x) => x.id === id)) return batch(() => { setStore( @@ -58,13 +115,18 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess setStore("active", remaining[0]?.id) } }) + } + const unsubExited = sdk.event.on("pty.exited", (event) => removeExited(event.properties.id)) + const unsubDeleted = sdk.event.on("pty.deleted", (event) => removeExited(event.properties.id)) + onCleanup(() => { + unsubExited() + unsubDeleted() }) - onCleanup(unsub) const meta = { migrated: false } createEffect(() => { - if (!ready()) return + if (!persistenceReady()) return if (meta.migrated) return meta.migrated = true @@ -82,10 +144,13 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess }) return { - ready, + has: (id: string) => store.all.some((pty) => pty.id === id), + refresh, + ready: () => persistenceReady() && hydrated(), all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new(opts?: { title?: string }) { + const session = currentSession() if (!session || session === "new") { return Promise.reject(new Error("Create or open a session before starting a terminal.")) } @@ -104,7 +169,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess (number) => !existingTitleNumbers.has(number), ) ?? 1 - return sdk.client.pty + return client.pty .create({ sessionID: session, title: opts?.title ?? `Terminal ${nextNumber}`, @@ -116,6 +181,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess id, title: pty.data?.title ?? "Terminal", titleNumber: nextNumber, + sessionID: pty.data?.sessionID ?? session, } setStore("all", (all) => { const newAll = [...all, newTerminal] @@ -134,7 +200,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess if (index !== -1) { setStore("all", index, (existing) => ({ ...existing, ...pty })) } - sdk.client.pty + client.pty .update({ ptyID: pty.id, title: pty.title, @@ -148,29 +214,34 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty - .create({ - sessionID: session, - title: pty.title, - }) - .catch((e) => { - console.error("Failed to clone terminal", e) - return undefined - }) - if (!clone?.data) return + const session = currentSession() + if (!session || session === "new") { + throw new Error("Create or open a session before reconnecting a terminal.") + } + const clone = await client.pty.create({ + sessionID: session, + title: pty.title, + }) + if (!clone.data) throw new Error("The server did not return a replacement terminal.") const active = store.active === pty.id + const replacement = { + id: clone.data.id, + title: clone.data.title ?? pty.title, + titleNumber: pty.titleNumber, + sessionID: clone.data.sessionID ?? session, + } batch(() => { - setStore("all", index, { - id: clone.data.id, - title: clone.data.title ?? pty.title, - titleNumber: pty.titleNumber, - }) + setStore("all", index, replacement) if (active) { setStore("active", clone.data.id) } }) + await client.pty.remove({ ptyID: pty.id }).catch((error) => { + console.error("Failed to close replaced terminal", error) + }) + return replacement }, open(id: string) { setStore("active", id) @@ -198,7 +269,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess setStore("all", filtered) }) - await sdk.client.pty.remove({ ptyID: id }).catch((e) => { + await client.pty.remove({ ptyID: id }).catch((e) => { console.error("Failed to close terminal", e) }) }, @@ -233,7 +304,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(disposeAll) const prune = () => { - while (cache.size > MAX_TERMINAL_SESSIONS) { + while (cache.size > MAX_TERMINAL_PROJECTS) { const first = cache.keys().next().value if (!first) return const entry = cache.get(first) @@ -242,17 +313,23 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const load = (dir: string, session?: string) => { - const key = `${dir}:${session ?? "new"}` + const load = (dir: string) => { + const key = dir const existing = cache.get(key) if (existing) { cache.delete(key) cache.set(key, existing) + void existing.value.refresh() return existing.value } const entry = createRoot((dispose) => ({ - value: createTerminalSession(sdk, dir, session), + value: createProjectTerminalSession( + sdk, + dir, + () => params.id, + untrack(() => params.id), + ), dispose, })) @@ -261,18 +338,22 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => load(sdk.scope, params.id)) + // Session navigation changes only the accessor used for future mutations. + // The project registry, PTY objects, mounted terminal emulators, and their + // WebSockets retain identity until the project itself changes. + const workspace = createMemo(() => load(sdk.scope)) + const owner = (id: string) => Array.from(cache.values()).find((entry) => entry.value.has(id))?.value ?? workspace() return { ready: () => workspace().ready(), all: () => workspace().all(), active: () => workspace().active(), new: (opts?: { title?: string }) => workspace().new(opts), - update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty), - clone: (id: string) => workspace().clone(id), - open: (id: string) => workspace().open(id), - close: (id: string) => workspace().close(id), - move: (id: string, to: number) => workspace().move(id, to), + update: (pty: Partial<LocalPTY> & { id: string }) => owner(pty.id).update(pty), + clone: (id: string) => owner(id).clone(id), + open: (id: string) => owner(id).open(id), + close: (id: string) => owner(id).close(id), + move: (id: string, to: number) => owner(id).move(id, to), next: () => workspace().next(), previous: () => workspace().previous(), } diff --git a/frontend/workspace/src/data/DataTableView.css b/frontend/workspace/src/data/DataTableView.css new file mode 100644 index 00000000..46de86ff --- /dev/null +++ b/frontend/workspace/src/data/DataTableView.css @@ -0,0 +1,523 @@ +.data-table-view { + display: flex; + width: 100%; + min-width: 0; + min-height: 100%; + flex: 1; + flex-direction: column; + overflow: hidden; + background: var(--color-surface-solid); + color: var(--color-text); + container: data-table / inline-size; +} + +.data-table-toolbar { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto; + min-width: 0; + flex: none; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-solid); +} + +.data-table-summary { + display: inline-flex; + align-items: baseline; + gap: 5px; + color: var(--color-text-faint); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.data-table-summary strong { + color: var(--color-text); + font-size: 12.5px; + font-weight: var(--font-weight-medium); +} + +.data-table-search { + position: relative; + display: flex; + min-width: 0; + align-items: center; +} + +.data-table-search input { + width: 100%; + min-width: 0; + height: 32px; + padding: 0 90px 0 10px; + border: 0; + border-radius: var(--atlas-radius-xs); + outline: 1px solid transparent; + background: var(--color-bg-subtle); + color: var(--color-text); + font: inherit; + font-size: 12px; + transition: + background-color 140ms ease, + outline-color 140ms ease; +} + +.data-table-search input:hover { + background: var(--color-bg-elevated); +} + +.data-table-search input:focus-visible { + outline-color: var(--color-border-strong); +} + +.data-table-search input::placeholder { + color: var(--color-text-faint); +} + +.data-table-matches { + position: absolute; + right: 9px; + color: var(--color-text-faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.data-table-actions { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; +} + +.data-table-actions button, +.data-table-actions select, +.data-table-pagination button, +.data-table-plot button { + min-height: 32px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.data-table-actions button, +.data-table-pagination button, +.data-table-plot button { + padding: 0 9px; +} + +.data-table-actions select { + max-width: 150px; + padding: 0 24px 0 8px; + background: var(--color-bg-subtle); +} + +.data-table-actions button:hover:not(:disabled), +.data-table-actions select:hover:not(:disabled), +.data-table-pagination button:hover:not(:disabled), +.data-table-plot button:hover:not(:disabled) { + background: var(--color-bg-elevated); + color: var(--color-text); +} + +.data-table-actions button.is-active { + background: var(--color-text); + color: var(--color-bg); +} + +.data-table-actions button:focus-visible, +.data-table-actions select:focus-visible, +.data-table-pagination button:focus-visible, +.data-table-plot button:focus-visible, +.data-table-scroll:focus-visible { + outline: 1px solid var(--color-text); + outline-offset: -2px; +} + +.data-table-actions :disabled, +.data-table-pagination button:disabled { + cursor: default; + opacity: 0.38; +} + +.data-table-schema { + display: flex; + min-height: 0; + flex: none; + gap: 8px; + padding: 8px 12px; + overflow: auto hidden; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); +} + +.data-table-schema-card { + width: 176px; + flex: 0 0 176px; + padding: 8px 10px; + border: 0; + border-radius: var(--atlas-radius-xs); + background: var(--color-surface-solid); +} + +.data-table-schema-name { + overflow: hidden; + color: var(--color-text); + font-family: var(--font-code); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table-schema-meta { + display: flex; + gap: 8px; + margin-top: 5px; + color: var(--color-text-faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.data-table-missing-track { + height: 3px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--color-border); +} + +.data-table-missing-value { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-warning, #a66d32); +} + +.data-table-scroll { + position: relative; + min-width: 0; + min-height: 0; + flex: 1; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.data-table-scroll table { + width: max-content; + min-width: 100%; + border-spacing: 0; + border-collapse: separate; + color: var(--color-text-muted); + font-family: var(--font-code); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.data-table-scroll thead th { + position: sticky; + top: 0; + z-index: 2; + height: 42px; + min-width: 136px; + max-width: 280px; + padding: 0; + border-bottom: 1px solid var(--color-border-strong); + background: color-mix(in srgb, var(--color-surface-solid) 94%, var(--color-bg-subtle)); + text-align: left; +} + +.data-table-scroll thead th > button { + display: flex; + width: 100%; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + border: 0; + background: transparent; + color: var(--color-text-muted); + font: inherit; + text-align: left; + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.data-table-scroll thead th > button:hover { + background: var(--color-bg-subtle); + color: var(--color-text); +} + +.data-table-scroll thead th > button:focus-visible { + outline: 1px solid var(--color-text); + outline-offset: -2px; +} + +.data-table-column-label { + display: grid; + min-width: 0; + gap: 1px; +} + +.data-table-column-name { + overflow: hidden; + color: inherit; + font-weight: var(--font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table-column-type { + color: var(--color-text-faint); + font-family: var(--font-sans); + font-size: 9.5px; + font-weight: var(--font-weight-regular); +} + +.data-table-sort { + flex: none; + color: var(--color-text-faint); + font-family: var(--font-sans); + font-size: 11px; +} + +.data-table-scroll tbody td, +.data-table-scroll tbody th { + height: 34px; + max-width: 320px; + padding: 0 10px; + overflow: hidden; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-solid); + font-weight: var(--font-weight-regular); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table-scroll tbody tr:hover > td, +.data-table-scroll tbody tr:hover > th { + background: var(--color-bg-subtle); + color: var(--color-text); +} + +.data-table-index-head, +.data-table-index { + position: sticky !important; + left: 0; + z-index: 3 !important; + width: 52px; + min-width: 52px !important; + max-width: 52px !important; + color: var(--color-text-faint); + text-align: right !important; + box-shadow: 1px 0 0 var(--color-border); +} + +.data-table-index-head { + padding: 0 10px !important; +} + +.data-table-index { + z-index: 1 !important; + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.data-table-empty-value { + color: var(--color-text-faint); +} + +.data-table-pagination { + display: flex; + min-height: 40px; + flex: none; + align-items: center; + justify-content: center; + gap: 8px; + padding: 4px 12px; + border-top: 1px solid var(--color-border); + background: var(--color-surface-solid); +} + +.data-table-pagination span { + min-width: 0; + overflow: hidden; + color: var(--color-text-faint); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table-plot { + flex: none; + padding: 10px 12px 12px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-solid); +} + +.data-table-plot header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.data-table-plot strong { + overflow: hidden; + min-width: 0; + color: var(--color-text); + font-size: 12.5px; + font-weight: var(--font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table-metrics { + display: flex; + min-width: 0; + gap: 10px; + color: var(--color-text-faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.data-table-plot header > button { + margin-left: auto; +} + +.data-table-plot svg { + display: block; + width: 100%; + height: 120px; +} + +.data-table-plot line { + stroke: var(--color-border-strong); +} + +.data-table-plot rect { + fill: var(--color-text-muted); + opacity: 0.78; +} + +.data-table-empty { + display: grid; + min-height: 100%; + flex: 1; + place-content: center; + justify-items: center; + gap: 7px; + padding: 32px 20px; + text-align: center; +} + +.data-table-empty strong { + font-size: 14px; + font-weight: var(--font-weight-medium); +} + +.data-table-empty span { + max-width: 420px; + color: var(--color-text-faint); + font-size: 12px; + line-height: 1.5; + text-wrap: pretty; +} + +@container data-table (max-width: 720px) { + .data-table-toolbar { + grid-template-columns: 1fr; + gap: 6px; + } + + .data-table-summary { + grid-row: 1; + } + + .data-table-search { + grid-row: 2; + } + + .data-table-actions { + grid-row: 3; + overflow-x: auto; + padding-bottom: 1px; + scrollbar-width: none; + } + + .data-table-actions::-webkit-scrollbar { + display: none; + } + + .data-table-actions select { + max-width: 132px; + } +} + +@container data-table (max-width: 460px) { + .data-table-toolbar { + padding-inline: 10px; + } + + .data-table-schema { + padding-inline: 10px; + } + + .data-table-plot header { + align-items: flex-start; + flex-wrap: wrap; + } + + .data-table-metrics { + order: 3; + width: 100%; + overflow-x: auto; + } + + .data-table-pagination { + justify-content: space-between; + padding-inline: 8px; + } + + .data-table-pagination button { + padding-inline: 7px; + } +} + +@media (pointer: coarse) { + .data-table-actions button, + .data-table-actions select, + .data-table-pagination button, + .data-table-plot button, + .data-table-scroll thead th > button { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .data-table-search input, + .data-table-actions button, + .data-table-actions select, + .data-table-pagination button, + .data-table-plot button, + .data-table-scroll thead th > button { + transition: none; + } +} diff --git a/frontend/workspace/src/data/DataTableView.tsx b/frontend/workspace/src/data/DataTableView.tsx index 00ecd3ad..f437f687 100644 --- a/frontend/workspace/src/data/DataTableView.tsx +++ b/frontend/workspace/src/data/DataTableView.tsx @@ -1,16 +1,27 @@ -import { For, Show, createMemo, createSignal, type JSX } from "solid-js" -import { FONT_CODE, FONT_MONO, FONT_SANS } from "@/styles/tokens" +import { For, Show, createMemo, type JSX } from "solid-js" +import { createStore } from "solid-js/store" import { exportDelimited, parseTable, summarizeColumn, type DataTable, type TableFormat } from "./table" +import "./DataTableView.css" const PAGE_SIZE = 100 +interface TableViewState { + query: string + sort?: { index: number; direction: "asc" | "desc" } + page: number + schema: boolean + plot: boolean + column: number +} + export function DataTableView(props: { text: string; format: TableFormat; name: string }): JSX.Element { - const [query, setQuery] = createSignal("") - const [sort, setSort] = createSignal<{ index: number; direction: "asc" | "desc" }>() - const [page, setPage] = createSignal(0) - const [schema, setSchema] = createSignal(false) - const [plot, setPlot] = createSignal(false) - const [column, setColumn] = createSignal(0) + const [view, setView] = createStore<TableViewState>({ + query: "", + page: 0, + schema: false, + plot: false, + column: 0, + }) const parsed = createMemo(() => { try { @@ -30,13 +41,13 @@ export function DataTableView(props: { text: string; format: TableFormat; name: .filter((value) => value.type === "number") ?? [], ) const plottedColumn = () => - numeric().some((value) => value.index === column()) ? column() : (numeric()[0]?.index ?? 0) + numeric().some((value) => value.index === view.column) ? view.column : (numeric()[0]?.index ?? 0) const filtered = createMemo(() => { const data = table() if (!data) return [] - const term = query().trim().toLowerCase() + const term = view.query.trim().toLowerCase() const rows = term ? data.rows.filter((row) => row.some((value) => value.toLowerCase().includes(term))) : data.rows - const order = sort() + const order = view.sort if (!order) return rows const type = data.schema[order.index]?.type ?? "string" return rows @@ -50,17 +61,23 @@ export function DataTableView(props: { text: string; format: TableFormat; name: .map((value) => value.row) }) const pages = () => Math.max(1, Math.ceil(filtered().length / PAGE_SIZE)) - const visible = () => filtered().slice(page() * PAGE_SIZE, (page() + 1) * PAGE_SIZE) + const visible = () => filtered().slice(view.page * PAGE_SIZE, (view.page + 1) * PAGE_SIZE) const sortBy = (index: number) => { - setPage(0) - setSort((current) => { + setView("page", 0) + setView("sort", (current) => { if (!current || current.index !== index) return { index, direction: "asc" } if (current.direction === "asc") return { index, direction: "desc" } return undefined }) } + const sortState = (index: number) => { + const active = view.sort + if (!active || active.index !== index) return "none" as const + return active.direction === "asc" ? ("ascending" as const) : ("descending" as const) + } + const download = () => { const data = table() if (!data) return @@ -74,210 +91,128 @@ export function DataTableView(props: { text: string; format: TableFormat; name: } return ( - <div - data-component="data-table" - style={{ - height: "100%", - "min-height": "100%", - display: "flex", - "flex-direction": "column", - background: "var(--color-bg-subtle)", - }} - > + <div class="data-table-view" data-component="data-table"> <Show when={table()} fallback={ - <div style={empty()}> - <strong style={{ "font-family": FONT_SANS, "font-size": "14px" }}>Could not read this table</strong> - <span style={{ "font-family": FONT_MONO, "font-size": "11px", color: "var(--color-text-faint)" }}> - {parsed().error} - </span> - </div> + <section class="data-table-empty" role="alert"> + <strong>Couldn’t read this table</strong> + <span>{parsed().error}</span> + </section> } > {(data) => ( <> - <div - style={{ - display: "flex", - "align-items": "center", - gap: "8px", - padding: "10px 12px", - border: "0", - "border-bottom": "1px solid var(--color-border)", - background: "var(--color-bg)", - "flex-wrap": "wrap", - }} - > - <div style={{ display: "flex", "align-items": "baseline", gap: "6px", "margin-right": "4px" }}> - <strong style={{ "font-family": FONT_SANS, "font-size": "12px", color: "var(--color-text)" }}> - {data().totalRows.toLocaleString()} rows - </strong> - <span style={{ "font-family": FONT_MONO, "font-size": "9px", color: "var(--color-text-faint)" }}> - × {data().columns.length} columns - </span> + <header class="data-table-toolbar"> + <div class="data-table-summary" aria-label="Dataset dimensions"> + <strong>{data().totalRows.toLocaleString()} rows</strong> + <span aria-hidden="true">×</span> + <span>{data().columns.length.toLocaleString()} columns</span> </div> - <input - data-action="table-filter" - aria-label="Filter rows" - placeholder="filter every column…" - value={query()} - onInput={(event) => { - setQuery(event.currentTarget.value) - setPage(0) - }} - style={input()} - /> - <Show when={query()}> - <span style={{ "font-family": FONT_MONO, "font-size": "9px", color: "var(--color-text-faint)" }}> - {filtered().length.toLocaleString()} matches - </span> - </Show> - <div style={{ flex: 1 }} /> - <button - type="button" - data-action="table-schema" - style={button(schema())} - onClick={() => setSchema((value) => !value)} - > - schema - </button> - <select - aria-label="Plot column" - value={String(plottedColumn())} - disabled={!numeric().length} - onChange={(event) => setColumn(Number(event.currentTarget.value))} - style={select()} - > - <For each={numeric()}>{(value) => <option value={value.index}>{value.name}</option>}</For> - </select> - <button - type="button" - data-action="table-plot" - disabled={!numeric().length} - style={button(plot())} - onClick={() => setPlot((value) => !value)} - > - distribution - </button> - <button type="button" data-action="table-export" style={button()} onClick={download}> - export filtered - </button> - </div> - <Show when={schema()}> - <div - class="atlas-scroll" - style={{ - display: "flex", - gap: "8px", - padding: "10px 12px", - overflow: "auto hidden", - border: "0", - "border-bottom": "1px solid var(--color-border)", - background: "color-mix(in srgb, var(--color-bg-subtle) 70%, var(--color-bg))", - }} - > + <label class="data-table-search"> + <span class="sr-only">Filter every column</span> + <input + type="search" + data-action="table-filter" + aria-label="Filter rows" + placeholder="Filter every column…" + value={view.query} + onInput={(event) => { + setView({ query: event.currentTarget.value, page: 0 }) + }} + /> + <Show when={view.query}> + <span class="data-table-matches" role="status"> + {filtered().length.toLocaleString()} matches + </span> + </Show> + </label> + + <div class="data-table-actions" aria-label="Table tools"> + <button + type="button" + data-action="table-schema" + classList={{ "is-active": view.schema }} + aria-pressed={view.schema} + onClick={() => setView("schema", (value) => !value)} + > + Schema + </button> + <label class="data-table-column-picker"> + <span class="sr-only">Distribution column</span> + <select + aria-label="Plot column" + value={String(plottedColumn())} + disabled={!numeric().length} + onChange={(event) => setView("column", Number(event.currentTarget.value))} + > + <For each={numeric()}>{(value) => <option value={value.index}>{value.name}</option>}</For> + </select> + </label> + <button + type="button" + data-action="table-plot" + classList={{ "is-active": view.plot }} + aria-pressed={view.plot} + disabled={!numeric().length} + onClick={() => setView("plot", (value) => !value)} + > + Distribution + </button> + <button type="button" data-action="table-export" onClick={download}> + Export filtered + </button> + </div> + </header> + + <Show when={view.schema}> + <section class="atlas-scroll data-table-schema" aria-label="Column schema"> <For each={data().schema}> {(value) => ( - <div - style={{ - width: "170px", - "flex-shrink": 0, - padding: "9px 10px", - border: "1px solid var(--color-border)", - "border-radius": "6px", - background: "var(--color-bg)", - }} - > - <div - title={value.name} - style={{ - "font-family": FONT_CODE, - "font-size": "10px", - color: "var(--color-text)", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - > + <article class="data-table-schema-card"> + <div class="data-table-schema-name" title={value.name}> {value.name} </div> - <div - style={{ - display: "flex", - gap: "8px", - "margin-top": "5px", - "font-family": FONT_MONO, - "font-size": "9px", - color: "var(--color-text-faint)", - }} - > + <div class="data-table-schema-meta"> <span>{value.type}</span> <span>{value.unique.toLocaleString()} unique</span> <span>{value.missing.toLocaleString()} missing</span> </div> - <div - title={`${value.missing} missing values`} - style={{ - height: "3px", - "margin-top": "8px", - "border-radius": "2px", - background: `linear-gradient(90deg, var(--color-warning, #c8923d) ${(value.missing / Math.max(1, data().totalRows)) * 100}%, var(--color-border) 0)`, - }} - /> - </div> + <div class="data-table-missing-track" title={`${value.missing} missing values`}> + <span + class="data-table-missing-value" + style={{ width: `${(value.missing / Math.max(1, data().totalRows)) * 100}%` }} + /> + </div> + </article> )} </For> - </div> + </section> </Show> - <Show when={plot() && numeric().length}> - <Histogram table={data()} index={plottedColumn()} onClose={() => setPlot(false)} /> + <Show when={view.plot && numeric().length}> + <Histogram table={data()} index={plottedColumn()} onClose={() => setView("plot", false)} /> </Show> - <div class="atlas-scroll" style={{ flex: 1, "min-height": 0, overflow: "auto" }}> - <table - style={{ - width: "max-content", - "min-width": "100%", - "border-collapse": "separate", - "border-spacing": 0, - "font-family": FONT_CODE, - "font-size": "10px", - "font-variant-numeric": "tabular-nums", - }} - > + <div class="atlas-scroll data-table-scroll" tabindex={0} aria-label={`${props.name} table preview`}> + <table> <thead> <tr> - <th style={{ ...head(), left: 0, "z-index": 3, width: "48px", "min-width": "48px" }}>#</th> + <th class="data-table-index-head" scope="col"> + <span class="sr-only">Row</span> + <span aria-hidden="true">#</span> + </th> <For each={data().columns}> {(name, index) => ( - <th style={head()}> - <button - type="button" - title={`Sort by ${name}`} - onClick={() => sortBy(index())} - style={{ - all: "unset", - cursor: "pointer", - width: "100%", - display: "flex", - "align-items": "center", - gap: "6px", - }} - > - <span - style={{ - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - }} - > - {name} + <th scope="col" aria-sort={sortState(index())}> + <button type="button" title={`Sort by ${name}`} onClick={() => sortBy(index())}> + <span class="data-table-column-label"> + <span class="data-table-column-name">{name}</span> + <span class="data-table-column-type">{data().schema[index()]?.type ?? "string"}</span> </span> - <span style={{ color: "var(--color-text-faint)" }}> - {sort()?.index === index() ? (sort()?.direction === "asc" ? "↑" : "↓") : ""} + <span class="data-table-sort" aria-hidden="true"> + {view.sort?.index === index() ? (view.sort?.direction === "asc" ? "↑" : "↓") : "↕"} </span> </button> </th> @@ -289,17 +224,15 @@ export function DataTableView(props: { text: string; format: TableFormat; name: <For each={visible()}> {(row, index) => ( <tr> - <td style={{ ...cell(), position: "sticky", left: 0, background: "var(--color-bg-subtle)" }}> - <span style={{ color: "var(--color-text-faint)" }}> - {(page() * PAGE_SIZE + index() + 1).toLocaleString()} - </span> - </td> + <th class="data-table-index" scope="row"> + {(view.page * PAGE_SIZE + index() + 1).toLocaleString()} + </th> <For each={data().columns}> {(_, column) => ( - <td title={row[column()] ?? ""} style={cell()}> + <td title={row[column()] ?? ""}> <Show when={(row[column()] ?? "") !== ""} - fallback={<span style={{ color: "var(--color-text-faint)" }}>—</span>} + fallback={<span class="data-table-empty-value">—</span>} > {row[column()]} </Show> @@ -313,40 +246,22 @@ export function DataTableView(props: { text: string; format: TableFormat; name: </table> </div> - <div - style={{ - height: "38px", - display: "flex", - "align-items": "center", - "justify-content": "center", - gap: "10px", - padding: "0 12px", - border: "0", - "border-top": "1px solid var(--color-border)", - background: "var(--color-bg)", - }} - > - <button - type="button" - disabled={page() === 0} - style={pager()} - onClick={() => setPage((value) => value - 1)} - > - ← previous + <footer class="data-table-pagination"> + <button type="button" disabled={view.page === 0} onClick={() => setView("page", (value) => value - 1)}> + Previous </button> - <span style={{ "font-family": FONT_MONO, "font-size": "9px", color: "var(--color-text-faint)" }}> - page {page() + 1} / {pages()} - {data().truncated ? " · preview capped at 5,000 rows" : ""} + <span> + Page {view.page + 1} of {pages()} + {data().truncated ? " · Preview capped at 5,000 rows" : ""} </span> <button type="button" - disabled={page() + 1 >= pages()} - style={pager()} - onClick={() => setPage((value) => value + 1)} + disabled={view.page + 1 >= pages()} + onClick={() => setView("page", (value) => value + 1)} > - next → + Next </button> - </div> + </footer> </> )} </Show> @@ -375,56 +290,34 @@ function Histogram(props: { table: DataTable; index: number; onClose: () => void const peak = () => Math.max(1, ...bars().map((value) => value.count)) return ( - <div - data-slot="table-plot" - style={{ - padding: "12px 16px 14px", - border: "0", - "border-bottom": "1px solid var(--color-border)", - background: "var(--color-bg)", - }} - > - <div style={{ display: "flex", "align-items": "center", gap: "12px", "margin-bottom": "10px" }}> - <strong style={{ "font-family": FONT_SANS, "font-size": "11px" }}> - {props.table.columns[props.index]} distribution - </strong> - <span style={metric()}>n {stats().count.toLocaleString()}</span> - <span style={metric()}>min {number(stats().min)}</span> - <span style={metric()}>mean {number(stats().mean)}</span> - <span style={metric()}>max {number(stats().max)}</span> - <div style={{ flex: 1 }} /> - <button type="button" style={pager()} onClick={props.onClose}> - close + <section class="data-table-plot" data-slot="table-plot"> + <header> + <strong>{props.table.columns[props.index]} distribution</strong> + <div class="data-table-metrics" aria-label="Distribution summary"> + <span>N {stats().count.toLocaleString()}</span> + <span>Min {number(stats().min)}</span> + <span>Mean {number(stats().mean)}</span> + <span>Max {number(stats().max)}</span> + </div> + <button type="button" onClick={props.onClose}> + Close </button> - </div> - <svg - viewBox="0 0 720 150" - role="img" - aria-label={`${props.table.columns[props.index]} histogram`} - style={{ width: "100%", height: "150px" }} - > - <line x1="24" y1="132" x2="710" y2="132" stroke="var(--color-border-strong)" /> + </header> + <svg viewBox="0 0 720 150" role="img" aria-label={`${props.table.columns[props.index]} histogram`}> + <line x1="24" y1="132" x2="710" y2="132" /> <For each={bars()}> {(bar, index) => { const width = 674 / Math.max(1, bars().length) const height = (bar.count / peak()) * 112 return ( - <rect - x={28 + index() * width} - y={132 - height} - width={Math.max(2, width - 3)} - height={height} - rx="2" - fill="var(--color-text-muted)" - opacity="0.82" - > + <rect x={28 + index() * width} y={132 - height} width={Math.max(2, width - 3)} height={height} rx="2"> <title>{bar.count} rows ) }} -
+ ) } @@ -440,114 +333,4 @@ function compare(left: string, right: string, type: string) { const number = (value: number | undefined) => value === undefined ? "—" : new Intl.NumberFormat(undefined, { maximumSignificantDigits: 5 }).format(value) -function input(): JSX.CSSProperties { - return { - width: "min(280px, 32vw)", - height: "28px", - padding: "0 9px", - border: "1px solid var(--color-border)", - "border-radius": "5px", - outline: "none", - background: "var(--color-bg-subtle)", - color: "var(--color-text)", - "font-family": FONT_SANS, - "font-size": "11px", - } -} - -function button(active = false): JSX.CSSProperties { - return { - cursor: "pointer", - height: "28px", - padding: "0 9px", - border: "1px solid var(--color-border)", - "border-radius": "5px", - background: active ? "var(--color-text)" : "var(--color-bg)", - color: active ? "var(--color-bg)" : "var(--color-text-muted)", - "font-family": FONT_MONO, - "font-size": "9px", - "font-weight": 600, - } -} - -function select(): JSX.CSSProperties { - return { - height: "28px", - "max-width": "150px", - padding: "0 24px 0 8px", - border: "1px solid var(--color-border)", - "border-radius": "5px", - background: "var(--color-bg)", - color: "var(--color-text-muted)", - "font-family": FONT_MONO, - "font-size": "9px", - } -} - -function head(): JSX.CSSProperties { - return { - position: "sticky", - top: 0, - "z-index": 2, - height: "34px", - "min-width": "130px", - "max-width": "260px", - padding: "0 10px", - "text-align": "left", - "font-weight": 600, - color: "var(--color-text-muted)", - background: "var(--color-bg)", - "border-right": "1px solid var(--color-border)", - "border-bottom": "1px solid var(--color-border-strong)", - } -} - -function cell(): JSX.CSSProperties { - return { - height: "31px", - "max-width": "320px", - padding: "0 10px", - overflow: "hidden", - "text-overflow": "ellipsis", - "white-space": "nowrap", - color: "var(--color-text-muted)", - background: "var(--color-bg)", - "border-right": "1px solid var(--color-border)", - "border-bottom": "1px solid var(--color-border)", - } -} - -function pager(): JSX.CSSProperties { - return { - all: "unset", - cursor: "pointer", - padding: "4px 7px", - "border-radius": "4px", - "font-family": FONT_MONO, - "font-size": "9px", - color: "var(--color-text-faint)", - } -} - -function metric(): JSX.CSSProperties { - return { - "font-family": FONT_MONO, - "font-size": "9px", - color: "var(--color-text-faint)", - } -} - -function empty(): JSX.CSSProperties { - return { - flex: 1, - display: "flex", - "flex-direction": "column", - "align-items": "center", - "justify-content": "center", - gap: "9px", - padding: "32px", - color: "var(--color-text)", - } -} - export default DataTableView diff --git a/frontend/workspace/src/data/table.test.ts b/frontend/workspace/src/data/table.test.ts index d530a384..2cb9b9a0 100644 --- a/frontend/workspace/src/data/table.test.ts +++ b/frontend/workspace/src/data/table.test.ts @@ -94,4 +94,35 @@ describe("data table integration", () => { expect(view).toContain('data-action="table-plot"') expect(view).toContain('data-action="table-export"') }) + + test("keeps table controls and metadata readable and sentence-cased", () => { + const view = read("./DataTableView.tsx") + const css = read("./DataTableView.css") + + expect(view).toContain('import "./DataTableView.css"') + expect(css).toContain("font-size: 12px") + expect(css).toContain("font-variant-numeric: tabular-nums") + expect(view).toMatch(/>\s*Schema\s*\s*Distribution\s*\s*Export filtered\s*\s*Previous\s*\s*Next\s*\s*Close\s* { + const view = read("./DataTableView.tsx") + const css = read("./DataTableView.css") + + expect(css).toContain("container: data-table / inline-size") + expect(css).toContain("@container data-table (max-width: 720px)") + expect(css).toMatch(/\.data-table-scroll\s*\{[^}]*overflow: auto/s) + expect(css).toMatch(/\.data-table-scroll thead th\s*\{[^}]*position: sticky/s) + expect(css).toMatch(/\.data-table-index-head,[\s\S]*\.data-table-index\s*\{[^}]*left: 0/s) + expect(view).toContain("aria-sort={sortState(index())}") + expect(view).toContain("data-table-column-type") + expect(css).toContain("min-height: 32px") + expect(css).toMatch(/@media \(pointer: coarse\)[\s\S]*min-height: 44px/) + }) }) diff --git a/frontend/workspace/src/entry.tsx b/frontend/workspace/src/entry.tsx index 08fca26e..a042c10c 100644 --- a/frontend/workspace/src/entry.tsx +++ b/frontend/workspace/src/entry.tsx @@ -67,8 +67,9 @@ const platform: Platform = { }) .catch(() => undefined) }, - checkUpdate: async () => { - const response = await openscienceFetch("/settings/updates", { headers: { Accept: "application/json" } }) + checkUpdate: async (options) => { + const query = options?.refresh ? "?refresh=1" : "" + const response = await openscienceFetch(`/settings/updates${query}`, { headers: { Accept: "application/json" } }) if (!response.ok) throw new Error(`Update check failed (${response.status})`) const result = (await response.json()) as { updateAvailable: boolean; latest?: string } return { updateAvailable: result.updateAvailable, version: result.latest } diff --git a/frontend/workspace/src/i18n/ar.ts b/frontend/workspace/src/i18n/ar.ts index 12778bfd..93f13b3b 100644 --- a/frontend/workspace/src/i18n/ar.ts +++ b/frontend/workspace/src/i18n/ar.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "مرفق", "prompt.placeholder.shell": "أدخل أمر shell...", - "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', + "prompt.placeholder.normal": "صِف مهمة البحث التي تريد العمل عليها…", "prompt.placeholder.summarizeComments": "لخّص التعليقات…", "prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc للخروج", - "prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية", - "prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟", - "prompt.example.3": "إصلاح الاختبارات المعطلة", - "prompt.example.4": "اشرح كيف تعمل المصادقة", - "prompt.example.5": "البحث عن وإصلاح الثغرات الأمنية", - "prompt.example.6": "إضافة اختبارات وحدة لخدمة المستخدم", - "prompt.example.7": "إعادة هيكلة هذه الدالة لتكون أكثر قابلية للقراءة", - "prompt.example.8": "ماذا يعني هذا الخطأ؟", - "prompt.example.9": "ساعدني في تصحيح هذه المشكلة", - "prompt.example.10": "توليد وثائق API", - "prompt.example.11": "تحسين استعلامات قاعدة البيانات", - "prompt.example.12": "إضافة التحقق من صحة الإدخال", - "prompt.example.13": "إنشاء مكون جديد لـ...", - "prompt.example.14": "كيف أقوم بنشر هذا المشروع؟", - "prompt.example.15": "مراجعة الكود الخاص بي لأفضل الممارسات", - "prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة", - "prompt.example.17": "اشرح نمط regex هذا", - "prompt.example.18": "تحويل هذا إلى TypeScript", - "prompt.example.19": "إضافة تسجيل الدخول (logging) في جميع أنحاء قاعدة التعليمات البرمجية", - "prompt.example.20": "ما هي التبعيات القديمة؟", - "prompt.example.21": "ساعدني في كتابة برنامج نصي للهجرة", - "prompt.example.22": "تنفيذ التخزين المؤقت لهذه النقطة النهائية", - "prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة", - "prompt.example.24": "إنشاء أمر CLI لـ...", - "prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟", - "prompt.popover.emptyResults": "لا توجد نتائج مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", diff --git a/frontend/workspace/src/i18n/br.ts b/frontend/workspace/src/i18n/br.ts index 7e33bb1f..7b959e28 100644 --- a/frontend/workspace/src/i18n/br.ts +++ b/frontend/workspace/src/i18n/br.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "anexo", "prompt.placeholder.shell": "Digite comando do shell...", - "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', + "prompt.placeholder.normal": "Descreva a tarefa de pesquisa que você quer desenvolver…", "prompt.placeholder.summarizeComments": "Resumir comentários…", "prompt.placeholder.summarizeComment": "Resumir comentário…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc para sair", - "prompt.example.1": "Corrigir um TODO no código", - "prompt.example.2": "Qual é a stack tecnológica deste projeto?", - "prompt.example.3": "Corrigir testes quebrados", - "prompt.example.4": "Explicar como funciona a autenticação", - "prompt.example.5": "Encontrar e corrigir vulnerabilidades de segurança", - "prompt.example.6": "Adicionar testes unitários para o serviço de usuário", - "prompt.example.7": "Refatorar esta função para melhor legibilidade", - "prompt.example.8": "O que significa este erro?", - "prompt.example.9": "Me ajude a depurar este problema", - "prompt.example.10": "Gerar documentação da API", - "prompt.example.11": "Otimizar consultas ao banco de dados", - "prompt.example.12": "Adicionar validação de entrada", - "prompt.example.13": "Criar um novo componente para...", - "prompt.example.14": "Como faço o deploy deste projeto?", - "prompt.example.15": "Revisar meu código para boas práticas", - "prompt.example.16": "Adicionar tratamento de erros a esta função", - "prompt.example.17": "Explicar este padrão regex", - "prompt.example.18": "Converter isto para TypeScript", - "prompt.example.19": "Adicionar logging em todo o código", - "prompt.example.20": "Quais dependências estão desatualizadas?", - "prompt.example.21": "Me ajude a escrever um script de migração", - "prompt.example.22": "Implementar cache para este endpoint", - "prompt.example.23": "Adicionar paginação a esta lista", - "prompt.example.24": "Criar um comando CLI para...", - "prompt.example.25": "Como funcionam as variáveis de ambiente aqui?", - "prompt.popover.emptyResults": "Nenhum resultado correspondente", "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", diff --git a/frontend/workspace/src/i18n/da.ts b/frontend/workspace/src/i18n/da.ts index b1e5b489..0a01f486 100644 --- a/frontend/workspace/src/i18n/da.ts +++ b/frontend/workspace/src/i18n/da.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "vedhæftning", "prompt.placeholder.shell": "Indtast shell-kommando...", - "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', + "prompt.placeholder.normal": "Beskriv den forskningsopgave, du vil arbejde med…", "prompt.placeholder.summarizeComments": "Opsummér kommentarer…", "prompt.placeholder.summarizeComment": "Opsummér kommentar…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc for at afslutte", - "prompt.example.1": "Ret en TODO i koden", - "prompt.example.2": "Hvad er teknologistakken for dette projekt?", - "prompt.example.3": "Ret ødelagte tests", - "prompt.example.4": "Forklar hvordan godkendelse fungerer", - "prompt.example.5": "Find og ret sikkerhedshuller", - "prompt.example.6": "Tilføj enhedstests for brugerservice", - "prompt.example.7": "Refaktorer denne funktion så den er mere læsbar", - "prompt.example.8": "Hvad betyder denne fejl?", - "prompt.example.9": "Hjælp mig med at debugge dette problem", - "prompt.example.10": "Generer API-dokumentation", - "prompt.example.11": "Optimer databaseforespørgsler", - "prompt.example.12": "Tilføj validering af input", - "prompt.example.13": "Opret en ny komponent til...", - "prompt.example.14": "Hvordan deployerer jeg dette projekt?", - "prompt.example.15": "Gennemgå min kode for bedste praksis", - "prompt.example.16": "Tilføj fejlhåndtering til denne funktion", - "prompt.example.17": "Forklar dette regex-mønster", - "prompt.example.18": "Konverter dette til TypeScript", - "prompt.example.19": "Tilføj logning i hele koden", - "prompt.example.20": "Hvilke afhængigheder er forældede?", - "prompt.example.21": "Hjælp mig med at skrive et migreringsscript", - "prompt.example.22": "Implementer caching for dette endpoint", - "prompt.example.23": "Tilføj sideinddeling til denne liste", - "prompt.example.24": "Opret en CLI-kommando til...", - "prompt.example.25": "Hvordan fungerer miljøvariabler her?", - "prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", diff --git a/frontend/workspace/src/i18n/de.ts b/frontend/workspace/src/i18n/de.ts index 5a3643be..f6dcc885 100644 --- a/frontend/workspace/src/i18n/de.ts +++ b/frontend/workspace/src/i18n/de.ts @@ -183,38 +183,12 @@ export const dict = { "common.attachment": "Anhang", "prompt.placeholder.shell": "Shell-Befehl eingeben...", - "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', + "prompt.placeholder.normal": "Beschreiben Sie die Forschungsaufgabe, an der Sie arbeiten möchten…", "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", "prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc zum Verlassen", - "prompt.example.1": "Ein TODO in der Codebasis beheben", - "prompt.example.2": "Was ist der Tech-Stack dieses Projekts?", - "prompt.example.3": "Fehlerhafte Tests beheben", - "prompt.example.4": "Erkläre, wie die Authentifizierung funktioniert", - "prompt.example.5": "Sicherheitslücken finden und beheben", - "prompt.example.6": "Unit-Tests für den Benutzerdienst hinzufügen", - "prompt.example.7": "Diese Funktion lesbarer gestalten", - "prompt.example.8": "Was bedeutet dieser Fehler?", - "prompt.example.9": "Hilf mir, dieses Problem zu debuggen", - "prompt.example.10": "API-Dokumentation generieren", - "prompt.example.11": "Datenbankabfragen optimieren", - "prompt.example.12": "Eingabevalidierung hinzufügen", - "prompt.example.13": "Neue Komponente erstellen für...", - "prompt.example.14": "Wie deploye ich dieses Projekt?", - "prompt.example.15": "Meinen Code auf Best Practices überprüfen", - "prompt.example.16": "Fehlerbehandlung zu dieser Funktion hinzufügen", - "prompt.example.17": "Erkläre dieses Regex-Muster", - "prompt.example.18": "Dies in TypeScript konvertieren", - "prompt.example.19": "Logging in der gesamten Codebasis hinzufügen", - "prompt.example.20": "Welche Abhängigkeiten sind veraltet?", - "prompt.example.21": "Hilf mir, ein Migrationsskript zu schreiben", - "prompt.example.22": "Caching für diesen Endpunkt implementieren", - "prompt.example.23": "Paginierung zu dieser Liste hinzufügen", - "prompt.example.24": "CLI-Befehl erstellen für...", - "prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?", - "prompt.popover.emptyResults": "Keine passenden Ergebnisse", "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", diff --git a/frontend/workspace/src/i18n/en-casing.test.ts b/frontend/workspace/src/i18n/en-casing.test.ts new file mode 100644 index 00000000..579ac3a1 --- /dev/null +++ b/frontend/workspace/src/i18n/en-casing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { dict } from "./en" + +const deliberateFragments = new Set([ + "provider.connect.oauth.code.visit.suffix", + "provider.connect.oauth.auto.visit.suffix", +]) + +describe("English interface copy", () => { + test("uses sentence case instead of accidental lowercase labels", () => { + const offenders = Object.entries(dict) + .filter(([key, value]) => { + const text = String(value).trimStart() + if (key === "model.provider.xai") return false + if (deliberateFragments.has(key)) return false + if (/^https?:\/\//.test(text)) return false + return /^[a-z]/.test(text) + }) + .map(([key, value]) => `${key}: ${value}`) + + expect(offenders).toEqual([]) + }) + + test("preserves technical acronym casing", () => { + expect(dict["dialog.mcp.title"]).toBe("MCPs") + expect(dict["model.input.pdf"]).toBe("PDF") + expect(dict["provider.connect.method.apiKey"]).toBe("API key") + expect(dict["status.popover.tab.lsp"]).toBe("LSP") + }) +}) diff --git a/frontend/workspace/src/i18n/en.ts b/frontend/workspace/src/i18n/en.ts index 21072cd3..f721bc3b 100644 --- a/frontend/workspace/src/i18n/en.ts +++ b/frontend/workspace/src/i18n/en.ts @@ -1,327 +1,304 @@ export const dict = { - "command.category.suggested": "suggested", - "command.category.view": "view", - "command.category.project": "project", - "command.category.provider": "provider", - "command.category.server": "server", - "command.category.session": "session", - "command.category.theme": "theme", - "command.category.language": "language", - "command.category.file": "file", - "command.category.context": "context", - "command.category.terminal": "terminal", - "command.category.model": "model", + "command.category.suggested": "Suggested", + "command.category.view": "View", + "command.category.project": "Project", + "command.category.provider": "Provider", + "command.category.server": "Server", + "command.category.session": "Session", + "command.category.theme": "Theme", + "command.category.language": "Language", + "command.category.file": "File", + "command.category.context": "Context", + "command.category.terminal": "Terminal", + "command.category.model": "Model", "command.category.mcp": "MCP", - "command.category.agent": "agent", - "command.category.permissions": "permissions", - "command.category.workspace": "workspace", - "command.category.settings": "settings", + "command.category.agent": "Agent", + "command.category.permissions": "Permissions", + "command.category.workspace": "Workspace", + "command.category.settings": "Settings", "theme.scheme.system": "System", "theme.scheme.light": "Light", "theme.scheme.dark": "Dark", - "command.sidebar.toggle": "toggle sidebar", - "command.project.open": "open project", - "command.provider.connect": "connect provider", - "command.server.switch": "switch server", - "command.settings.open": "open settings", - "command.session.previous": "previous session", - "command.session.next": "next session", - "command.session.archive": "archive session", - - "command.palette": "command palette", - - "command.theme.cycle": "cycle theme", - "command.theme.set": "use theme: {{theme}}", - "command.theme.scheme.cycle": "cycle color scheme", - "command.theme.scheme.set": "use color scheme: {{scheme}}", - - "command.language.cycle": "cycle language", - "command.language.set": "use language: {{language}}", - - "command.session.new": "new session", - "command.file.open": "open file", - "command.file.open.description": "search files and commands", - "command.context.addSelection": "add selection to context", - "command.context.addSelection.description": "add selected lines from the current file", - "command.terminal.toggle": "toggle terminal", - "command.fileTree.toggle": "toggle file tree", - "command.review.toggle": "toggle review", - "command.terminal.new": "new terminal", - "command.terminal.new.description": "create a new terminal tab", - "command.steps.toggle": "toggle steps", - "command.steps.toggle.description": "show or hide steps for the current message", - "command.message.previous": "previous message", - "command.message.previous.description": "go to the previous user message", - "command.message.next": "next message", - "command.message.next.description": "go to the next user message", - "command.model.choose": "choose model", - "command.model.choose.description": "select a different model", - "command.mcp.toggle": "toggle MCPs", - "command.mcp.toggle.description": "toggle MCPs", - "command.agent.cycle": "cycle agent", - "command.agent.cycle.description": "switch to the next agent", - "command.agent.cycle.reverse": "cycle agent backwards", - "command.agent.cycle.reverse.description": "switch to the previous agent", - "command.tier.cycle": "toggle fast mode", - "command.tier.cycle.description": "toggle fast mode for supported models", - "command.model.variant.cycle": "cycle thinking effort", - "command.model.variant.cycle.description": "switch to the next effort level", - "command.permissions.autoaccept.enable": "auto-accept edits", - "command.permissions.autoaccept.disable": "stop auto-accepting edits", - "command.session.undo": "undo", - "command.session.undo.description": "undo the last message", - "command.session.redo": "redo", - "command.session.redo.description": "redo the last undone message", - "command.session.compact": "compact session", - "command.session.compact.description": "summarize the session to reduce context size", - "command.session.fork": "fork from message", - "command.session.fork.description": "create a new session from a previous message", - "command.session.share": "share session", - "command.session.share.description": "share this session and copy the URL to clipboard", - "command.session.unshare": "unshare session", - "command.session.unshare.description": "stop sharing this session", - - "palette.search.placeholder": "search files and commands", - "palette.empty": "no results found", - "palette.group.commands": "commands", - "palette.group.files": "files", - - "dialog.provider.search.placeholder": "search providers", - "dialog.provider.empty": "no providers found", - "dialog.provider.group.popular": "popular", - "dialog.provider.group.other": "other", - "dialog.provider.tag.recommended": "recommended", - "dialog.provider.synsci.note": "curated models including Claude, GPT, Gemini and more", - "dialog.provider.anthropic.note": "direct access to Claude models, including Pro and Max", + "command.sidebar.toggle": "Toggle sidebar", + "command.project.open": "Open project", + "command.provider.connect": "Connect provider", + "command.server.switch": "Switch server", + "command.settings.open": "Open settings", + "command.session.previous": "Previous session", + "command.session.next": "Next session", + "command.session.archive": "Archive session", + + "command.palette": "Command palette", + + "command.theme.cycle": "Cycle theme", + "command.theme.set": "Use theme: {{theme}}", + "command.theme.scheme.cycle": "Cycle color scheme", + "command.theme.scheme.set": "Use color scheme: {{scheme}}", + + "command.language.cycle": "Cycle language", + "command.language.set": "Use language: {{language}}", + + "command.session.new": "New session", + "command.file.open": "Open file", + "command.file.open.description": "Search files and commands", + "command.context.addSelection": "Add selection to context", + "command.context.addSelection.description": "Add selected lines from the current file", + "command.terminal.toggle": "Toggle terminal", + "command.fileTree.toggle": "Toggle file tree", + "command.review.toggle": "Toggle review", + "command.terminal.new": "New terminal", + "command.terminal.new.description": "Create a new terminal tab", + "command.steps.toggle": "Toggle steps", + "command.steps.toggle.description": "Show or hide steps for the current message", + "command.message.previous": "Previous message", + "command.message.previous.description": "Go to the previous user message", + "command.message.next": "Next message", + "command.message.next.description": "Go to the next user message", + "command.model.choose": "Choose model", + "command.model.choose.description": "Select a different model", + "command.mcp.toggle": "Toggle MCPs", + "command.mcp.toggle.description": "Toggle MCPs", + "command.agent.cycle": "Cycle agent", + "command.agent.cycle.description": "Switch to the next agent", + "command.agent.cycle.reverse": "Cycle agent backwards", + "command.agent.cycle.reverse.description": "Switch to the previous agent", + "command.tier.cycle": "Toggle fast mode", + "command.tier.cycle.description": "Toggle fast mode for supported models", + "command.model.variant.cycle": "Cycle thinking effort", + "command.model.variant.cycle.description": "Switch to the next effort level", + "command.permissions.autoaccept.enable": "Auto-accept edits", + "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.session.undo": "Undo", + "command.session.undo.description": "Undo the last message", + "command.session.redo": "Redo", + "command.session.redo.description": "Redo the last undone message", + "command.session.compact": "Compact session", + "command.session.compact.description": "Summarize the session to reduce context size", + "command.session.fork": "Fork from message", + "command.session.fork.description": "Create a new session from a previous message", + "command.session.share": "Share session", + "command.session.share.description": "Share this session and copy the URL to clipboard", + "command.session.unshare": "Unshare session", + "command.session.unshare.description": "Stop sharing this session", + + "palette.search.placeholder": "Search files and commands", + "palette.empty": "No results found", + "palette.group.commands": "Commands", + "palette.group.files": "Files", + + "dialog.provider.search.placeholder": "Search providers", + "dialog.provider.empty": "No providers found", + "dialog.provider.group.popular": "Popular", + "dialog.provider.group.other": "Other", + "dialog.provider.tag.recommended": "Recommended", + "dialog.provider.synsci.note": "Curated models including Claude, GPT, Gemini and more", + "dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max", "dialog.provider.copilot.note": "Claude models for coding assistance", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", "dialog.provider.google.note": "Gemini models for fast, structured responses", - "dialog.provider.openrouter.note": "access all supported models from one provider", - "dialog.provider.vercel.note": "unified access to AI models with smart routing", + "dialog.provider.openrouter.note": "Access all supported models from one provider", + "dialog.provider.vercel.note": "Unified access to AI models with smart routing", - "dialog.model.select.title": "select model", - "dialog.model.search.placeholder": "search models", - "dialog.model.empty": "no model results", - "dialog.model.manage": "manage models", - "dialog.model.manage.description": "customize which models appear in the model selector.", + "dialog.model.select.title": "Select model", + "dialog.model.search.placeholder": "Search models", + "dialog.model.empty": "No model results", + "dialog.model.manage": "Manage models", + "dialog.model.manage.description": "Customize which models appear in the model selector.", - "dialog.model.unpaid.freeModels.title": "free models from your connected providers", - "dialog.model.unpaid.empty": "no free models available yet — connect a provider to add models.", - "dialog.model.unpaid.addMore.title": "add more models from popular providers", + "dialog.model.unpaid.freeModels.title": "Free models from your connected providers", + "dialog.model.unpaid.empty": "No free models available yet — connect a provider to add models.", + "dialog.model.unpaid.addMore.title": "Add more models from popular providers", - "dialog.provider.viewAll": "show more providers", + "dialog.provider.viewAll": "Show more providers", - "provider.connect.title": "connect {{provider}}", - "provider.connect.title.anthropicProMax": "login with Claude Pro/Max", - "provider.connect.selectMethod": "select login method for {{provider}}.", + "provider.connect.title": "Connect {{provider}}", + "provider.connect.title.anthropicProMax": "Login with Claude Pro/Max", + "provider.connect.selectMethod": "Select login method for {{provider}}.", "provider.connect.method.apiKey": "API key", - "provider.connect.status.inProgress": "authorization in progress...", - "provider.connect.status.waiting": "waiting for authorization...", - "provider.connect.status.failed": "authorization failed: {{error}}", + "provider.connect.status.inProgress": "Authorization in progress...", + "provider.connect.status.waiting": "Waiting for authorization...", + "provider.connect.status.failed": "Authorization failed: {{error}}", "provider.connect.apiKey.description": - "enter your {{provider}} API key to connect your account and use {{provider}} models in OpenScience.", + "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenScience.", "provider.connect.apiKey.label": "{{provider}} API key", "provider.connect.apiKey.placeholder": "API key", "provider.connect.apiKey.required": "API key is required", - "provider.connect.oauth.code.visit.prefix": "visit ", - "provider.connect.oauth.code.visit.link": "this link", + "provider.connect.oauth.code.visit.prefix": "Visit ", + "provider.connect.oauth.code.visit.link": "This link", "provider.connect.oauth.code.visit.suffix": " to collect your authorization code to connect your account and use {{provider}} models in OpenScience.", "provider.connect.oauth.code.label": "{{method}} authorization code", - "provider.connect.oauth.code.placeholder": "authorization code", - "provider.connect.oauth.code.required": "authorization code is required", - "provider.connect.oauth.code.invalid": "invalid authorization code", - "provider.connect.oauth.auto.visit.prefix": "visit ", - "provider.connect.oauth.auto.visit.link": "this link", + "provider.connect.oauth.code.placeholder": "Authorization code", + "provider.connect.oauth.code.required": "Authorization code is required", + "provider.connect.oauth.code.invalid": "Invalid authorization code", + "provider.connect.oauth.auto.visit.prefix": "Visit ", + "provider.connect.oauth.auto.visit.link": "This link", "provider.connect.oauth.auto.visit.suffix": " and enter the code below to connect your account and use {{provider}} models in OpenScience.", - "provider.connect.oauth.auto.confirmationCode": "confirmation code", + "provider.connect.oauth.auto.confirmationCode": "Confirmation code", "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", - "model.tag.free": "free", - "model.tag.latest": "latest", + "model.tag.free": "Free", + "model.tag.latest": "Latest", "model.tag.pricing": "{{input}} / {{output}}", "model.provider.anthropic": "Anthropic", "model.provider.openai": "OpenAI", "model.provider.google": "Google", "model.provider.xai": "xAI", "model.provider.meta": "Meta", - "model.input.text": "text", - "model.input.image": "image", - "model.input.audio": "audio", - "model.input.video": "video", - "model.input.pdf": "pdf", - "model.tooltip.allows": "allows: {{inputs}}", - "model.tooltip.reasoning.allowed": "allows reasoning", - "model.tooltip.reasoning.none": "no reasoning", - "model.tooltip.context": "context limit {{limit}}", - "model.tooltip.pricing.free": "free — $0 per token", + "model.input.text": "Text", + "model.input.image": "Image", + "model.input.audio": "Audio", + "model.input.video": "Video", + "model.input.pdf": "PDF", + "model.tooltip.allows": "Allows: {{inputs}}", + "model.tooltip.reasoning.allowed": "Allows reasoning", + "model.tooltip.reasoning.none": "No reasoning", + "model.tooltip.context": "Context limit {{limit}}", + "model.tooltip.pricing.free": "Free — $0 per token", "model.tooltip.pricing.io": "{{input}}/M in · {{output}}/M out", - "model.tooltip.pricing.cache": "cache read/write {{cache}} per M", + "model.tooltip.pricing.cache": "Cache read/write {{cache}} per M", - "common.search.placeholder": "search", - "common.goBack": "back", - "common.goForward": "forward", - "common.loading": "loading", + "common.search.placeholder": "Search", + "common.goBack": "Back", + "common.goForward": "Forward", + "common.loading": "Loading", "common.loading.ellipsis": "...", - "common.cancel": "cancel", - "common.connect": "connect", - "common.disconnect": "disconnect", - "common.submit": "submit", - "common.save": "save", - "common.saving": "saving...", - "common.default": "default", - "common.attachment": "attachment", - - "prompt.placeholder.shell": "enter shell command...", - "prompt.placeholder.normal": 'ask anything... "{{example}}"', - "prompt.placeholder.summarizeComments": "summarize comments…", - "prompt.placeholder.summarizeComment": "summarize comment…", - "prompt.mode.shell": "shell", - "prompt.mode.shell.exit": "esc to exit", - - "prompt.example.1": "fix a TODO in the codebase", - "prompt.example.2": "what is the tech stack of this project?", - "prompt.example.3": "fix broken tests", - "prompt.example.4": "explain how authentication works", - "prompt.example.5": "find and fix security vulnerabilities", - "prompt.example.6": "add unit tests for the user service", - "prompt.example.7": "refactor this function to be more readable", - "prompt.example.8": "what does this error mean?", - "prompt.example.9": "help me debug this issue", - "prompt.example.10": "generate API documentation", - "prompt.example.11": "optimize database queries", - "prompt.example.12": "add input validation", - "prompt.example.13": "create a new component for...", - "prompt.example.14": "how do I deploy this project?", - "prompt.example.15": "review my code for best practices", - "prompt.example.16": "add error handling to this function", - "prompt.example.17": "explain this regex pattern", - "prompt.example.18": "convert this to TypeScript", - "prompt.example.19": "add logging throughout the codebase", - "prompt.example.20": "what dependencies are outdated?", - "prompt.example.21": "help me write a migration script", - "prompt.example.22": "implement caching for this endpoint", - "prompt.example.23": "add pagination to this list", - "prompt.example.24": "create a CLI command for...", - "prompt.example.25": "how do environment variables work here?", - - "prompt.popover.emptyResults": "no matching results", - "prompt.popover.emptyCommands": "no matching commands", - "prompt.dropzone.label": "drop images or PDFs here", - "prompt.slash.badge.custom": "custom", - "prompt.context.active": "active", - "prompt.context.includeActiveFile": "include active file", - "prompt.context.removeActiveFile": "remove active file from context", - "prompt.context.removeFile": "remove file from context", - "prompt.action.attachFile": "attach file", - "prompt.attachment.remove": "remove attachment", - "prompt.action.send": "send", - "prompt.action.stop": "stop", - - "prompt.toast.pasteUnsupported.title": "unsupported paste", - "prompt.toast.pasteUnsupported.description": "only images or PDFs can be pasted here.", - "prompt.toast.modelAgentRequired.title": "select an agent and model", - "prompt.toast.modelAgentRequired.description": "choose an agent and model before sending a prompt.", - "prompt.toast.worktreeCreateFailed.title": "failed to create worktree", - "prompt.toast.sessionCreateFailed.title": "failed to create session", - "prompt.toast.shellSendFailed.title": "failed to send shell command", - "prompt.toast.commandSendFailed.title": "failed to send command", - "prompt.toast.promptSendFailed.title": "failed to send prompt", - - "dialog.mcp.title": "mcps", + "common.cancel": "Cancel", + "common.connect": "Connect", + "common.disconnect": "Disconnect", + "common.submit": "Submit", + "common.save": "Save", + "common.saving": "Saving...", + "common.default": "Default", + "common.attachment": "Attachment", + + "prompt.placeholder.shell": "Enter shell command…", + "prompt.placeholder.normal": "Describe the research task you want to work through…", + "prompt.placeholder.summarizeComments": "Summarize comments…", + "prompt.placeholder.summarizeComment": "Summarize comment…", + "prompt.mode.shell": "Shell", + "prompt.mode.shell.exit": "Esc to exit", + + "prompt.popover.emptyResults": "No matching results", + "prompt.popover.emptyCommands": "No matching commands", + "prompt.dropzone.label": "Drop files to attach", + "prompt.dropzone.hint": "Images, PDFs, text, code, and data · up to 20 MB each", + "prompt.slash.badge.custom": "Custom", + "prompt.context.active": "Active", + "prompt.context.includeActiveFile": "Include active file", + "prompt.context.removeActiveFile": "Remove active file from context", + "prompt.context.removeFile": "Remove file from context", + "prompt.action.attachFile": "Attach file", + "prompt.attachment.remove": "Remove attachment", + "prompt.action.send": "Send", + "prompt.action.stop": "Stop", + + "prompt.toast.pasteUnsupported.title": "Unsupported paste", + "prompt.toast.pasteUnsupported.description": "Paste a supported image, PDF, text, code, or scientific data file.", + "prompt.toast.modelAgentRequired.title": "Select an agent and model", + "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", + "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", + "prompt.toast.sessionCreateFailed.title": "Failed to create session", + "prompt.toast.shellSendFailed.title": "Failed to send shell command", + "prompt.toast.commandSendFailed.title": "Failed to send command", + "prompt.toast.promptSendFailed.title": "Failed to send prompt", + + "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} of {{total}} enabled", - "dialog.mcp.empty": "no MCPs configured", + "dialog.mcp.empty": "No MCPs configured", "dialog.lsp.empty": "LSPs auto-detected from file types", - "dialog.plugins.empty": "plugins configured in openscience.json", + "dialog.plugins.empty": "Plugins configured in openscience.json", - "mcp.status.connected": "connected", - "mcp.status.failed": "failed", - "mcp.status.needs_auth": "needs auth", - "mcp.status.disabled": "disabled", + "mcp.status.connected": "Connected", + "mcp.status.failed": "Failed", + "mcp.status.needs_auth": "Needs auth", + "mcp.status.disabled": "Disabled", - "dialog.fork.empty": "no messages to fork from", + "dialog.fork.empty": "No messages to fork from", - "dialog.directory.search.placeholder": "search folders", - "dialog.directory.empty": "no folders found", + "dialog.directory.search.placeholder": "Search folders", + "dialog.directory.empty": "No folders found", "dialog.server.title": "Servers", - "dialog.server.description": "switch which OpenScience server this app connects to.", - "dialog.server.search.placeholder": "search servers", - "dialog.server.empty": "no servers yet", - "dialog.server.add.title": "add a server", - "dialog.server.add.url": "server URL", + "dialog.server.description": "Switch which OpenScience server this app connects to.", + "dialog.server.search.placeholder": "Search servers", + "dialog.server.empty": "No servers yet", + "dialog.server.add.title": "Add a server", + "dialog.server.add.url": "Server URL", "dialog.server.add.placeholder": "http://localhost:4096", - "dialog.server.add.error": "could not connect to server", - "dialog.server.add.checking": "checking...", - "dialog.server.add.button": "add server", - "dialog.server.default.title": "default server", + "dialog.server.add.error": "Could not connect to server", + "dialog.server.add.checking": "Checking…", + "dialog.server.add.button": "Add Server", + "dialog.server.default.title": "Default server", "dialog.server.default.description": - "connect to this server on app launch instead of starting a local server. requires restart.", - "dialog.server.default.none": "no server selected", - "dialog.server.default.set": "set current server as default", - "dialog.server.default.clear": "clear", - "dialog.server.action.remove": "remove server", - - "dialog.server.menu.edit": "edit", - "dialog.server.menu.default": "set as default", - "dialog.server.menu.defaultRemove": "remove default", - "dialog.server.menu.delete": "delete", - "dialog.server.current": "current server", - "dialog.server.status.default": "default", - - "dialog.project.edit.title": "edit project", - "dialog.project.edit.name": "name", - "dialog.project.edit.icon": "icon", - "dialog.project.edit.icon.alt": "project icon", - "dialog.project.edit.icon.hint": "click or drag an image", - "dialog.project.edit.icon.recommended": "recommended: 128x128px", - "dialog.project.edit.color": "color", - "dialog.project.edit.color.select": "select {{color}} color", - "dialog.project.edit.worktree.startup": "workspace startup script", - "dialog.project.edit.worktree.startup.description": "runs after creating a new workspace (worktree).", - "dialog.project.edit.worktree.startup.placeholder": "e.g. bun install", - - "context.breakdown.title": "context breakdown", - "context.breakdown.note": 'approximate breakdown of input tokens. "other" includes tool definitions and overhead.', - "context.breakdown.system": "system", - "context.breakdown.user": "user", - "context.breakdown.assistant": "assistant", - "context.breakdown.tool": "tool calls", - "context.breakdown.other": "other", - - "context.systemPrompt.title": "system prompt", - "context.rawMessages.title": "raw messages", - - "context.stats.session": "session", - "context.stats.messages": "messages", - "context.stats.provider": "provider", - "context.stats.model": "model", - "context.stats.limit": "context limit", - "context.stats.totalTokens": "total tokens", - "context.stats.usage": "usage", - "context.stats.inputTokens": "input tokens", - "context.stats.outputTokens": "output tokens", - "context.stats.reasoningTokens": "reasoning tokens", - "context.stats.cacheTokens": "cache tokens (read/write)", - "context.stats.userMessages": "user messages", - "context.stats.assistantMessages": "assistant messages", - "context.stats.totalCost": "total cost", - "context.stats.sessionCreated": "session created", - "context.stats.lastActivity": "last activity", - - "context.usage.tokens": "tokens", - "context.usage.usage": "usage", - "context.usage.cost": "cost", - "context.usage.clickToView": "click to view context", - "context.usage.view": "view context usage", + "Connect to this server on app launch instead of starting a local server. Requires restart.", + "dialog.server.default.none": "No server selected", + "dialog.server.default.set": "Set Current Server as Default", + "dialog.server.default.clear": "Clear", + "dialog.server.action.remove": "Remove Server", + "dialog.server.action.cancel": "Cancel", + "dialog.server.action.save": "Save", + + "dialog.server.menu.edit": "Edit", + "dialog.server.menu.default": "Set as Default", + "dialog.server.menu.defaultRemove": "Remove Default", + "dialog.server.menu.delete": "Delete", + "dialog.server.current": "Current server", + "dialog.server.status.default": "Default", + + "dialog.project.edit.title": "Edit project", + "dialog.project.edit.name": "Name", + "dialog.project.edit.icon": "Icon", + "dialog.project.edit.icon.alt": "Project icon", + "dialog.project.edit.icon.hint": "Click or drag an image", + "dialog.project.edit.icon.recommended": "Recommended: 128x128px", + "dialog.project.edit.color": "Color", + "dialog.project.edit.color.select": "Select {{color}} color", + "dialog.project.edit.worktree.startup": "Workspace startup script", + "dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).", + "dialog.project.edit.worktree.startup.placeholder": "E.g. bun install", + + "context.breakdown.title": "Context breakdown", + "context.breakdown.note": 'Approximate breakdown of input tokens. "other" includes tool definitions and overhead.', + "context.breakdown.system": "System", + "context.breakdown.user": "User", + "context.breakdown.assistant": "Assistant", + "context.breakdown.tool": "Tool calls", + "context.breakdown.other": "Other", + + "context.systemPrompt.title": "System prompt", + "context.rawMessages.title": "Raw messages", + + "context.stats.session": "Session", + "context.stats.messages": "Messages", + "context.stats.provider": "Provider", + "context.stats.model": "Model", + "context.stats.limit": "Context limit", + "context.stats.totalTokens": "Total tokens", + "context.stats.usage": "Usage", + "context.stats.inputTokens": "Input tokens", + "context.stats.outputTokens": "Output tokens", + "context.stats.reasoningTokens": "Reasoning tokens", + "context.stats.cacheTokens": "Cache tokens (read/write)", + "context.stats.userMessages": "User messages", + "context.stats.assistantMessages": "Assistant messages", + "context.stats.totalCost": "Total cost", + "context.stats.sessionCreated": "Session created", + "context.stats.lastActivity": "Last activity", + + "context.usage.tokens": "Tokens", + "context.usage.usage": "Usage", + "context.usage.cost": "Cost", + "context.usage.clickToView": "Click to view context", + "context.usage.view": "View context usage", "language.en": "English", "language.zh": "简体中文", @@ -339,191 +316,192 @@ export const dict = { "language.br": "Português (Brasil)", "language.th": "ไทย", - "toast.language.title": "language", - "toast.language.description": "switched to {{language}}", + "toast.language.title": "Language", + "toast.language.description": "Switched to {{language}}", - "toast.theme.title": "theme switched", - "toast.scheme.title": "color scheme", + "toast.theme.title": "Theme switched", + "toast.scheme.title": "Color scheme", - "toast.permissions.autoaccept.on.title": "auto-accepting edits", - "toast.permissions.autoaccept.on.description": "edit and write permissions will be automatically approved", - "toast.permissions.autoaccept.off.title": "stopped auto-accepting edits", - "toast.permissions.autoaccept.off.description": "edit and write permissions will require approval", + "toast.permissions.autoaccept.on.title": "Auto-accepting edits", + "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", + "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", + "toast.permissions.autoaccept.off.description": "Edit and write permissions will require approval", - "toast.model.none.title": "no model selected", - "toast.model.none.description": "connect a provider to summarize this session", + "toast.model.none.title": "No model selected", + "toast.model.none.description": "Connect a provider to summarize this session", - "toast.file.loadFailed.title": "failed to load file", - "toast.file.listFailed.title": "failed to list files", + "toast.file.loadFailed.title": "Failed to load file", + "toast.file.listFailed.title": "Failed to list files", - "toast.context.noLineSelection.title": "no line selection", - "toast.context.noLineSelection.description": "select a line range in a file tab first.", + "toast.context.noLineSelection.title": "No line selection", + "toast.context.noLineSelection.description": "Select a line range in a file tab first.", - "toast.session.share.copyFailed.title": "failed to copy URL to clipboard", - "toast.session.share.success.title": "session shared", - "toast.session.share.success.description": "share URL copied to clipboard!", - "toast.session.share.failed.title": "failed to share session", - "toast.session.share.failed.description": "an error occurred while sharing the session", + "toast.session.share.copyFailed.title": "Failed to copy URL to clipboard", + "toast.session.share.success.title": "Session shared", + "toast.session.share.success.description": "Share URL copied to clipboard!", + "toast.session.share.failed.title": "Failed to share session", + "toast.session.share.failed.description": "An error occurred while sharing the session", - "toast.session.unshare.success.title": "session unshared", - "toast.session.unshare.success.description": "session unshared successfully!", - "toast.session.unshare.failed.title": "failed to unshare session", - "toast.session.unshare.failed.description": "an error occurred while unsharing the session", + "toast.session.unshare.success.title": "Session unshared", + "toast.session.unshare.success.description": "Session unshared successfully!", + "toast.session.unshare.failed.title": "Failed to unshare session", + "toast.session.unshare.failed.description": "An error occurred while unsharing the session", - "toast.session.listFailed.title": "failed to load sessions for {{project}}", + "toast.session.listFailed.title": "Failed to load sessions for {{project}}", - "toast.update.title": "update available", - "toast.update.description": "a new version of OpenScience ({{version}}) is now available to install.", - "toast.update.action.installRestart": "install and restart", - "toast.update.action.notYet": "not yet", + "toast.update.title": "Update available", + "toast.update.description": "A new version of OpenScience ({{version}}) is now available to install.", + "toast.update.action.installRestart": "Install and restart", + "toast.update.action.notYet": "Not yet", - "error.page.title": "something went wrong", - "error.page.description": "an error occurred while loading the application.", - "error.page.details.label": "error details", - "error.page.action.restart": "restart", - "error.page.action.checking": "checking...", - "error.page.action.checkUpdates": "check for updates", - "error.page.action.updateTo": "update to {{version}}", - "error.page.report.prefix": "please report this error to the OpenScience team", - "error.page.report.discord": "on Discord", - "error.page.version": "version: {{version}}", + "error.page.title": "Something went wrong", + "error.page.description": "An error occurred while loading the application.", + "error.page.details.label": "Error details", + "error.page.action.restart": "Restart", + "error.page.action.checking": "Checking…", + "error.page.action.checkUpdates": "Check for Updates", + "error.page.action.updateTo": "Update to {{version}}", + "error.page.report.prefix": "Please report this error to the OpenScience team", + "error.page.report.discord": "On Discord", + "error.page.version": "Version: {{version}}", "error.dev.rootNotFound": - "root element not found. did you forget to add it to your index.html? or maybe the id attribute got misspelled?", + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", - "error.globalSync.connectFailed": "could not connect to server. is there a server running at `{{url}}`?", + "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", - "error.chain.unknown": "unknown error", - "error.chain.causedBy": "caused by:", + "error.chain.unknown": "Unknown error", + "error.chain.causedBy": "Caused by:", "error.chain.apiError": "API error", - "error.chain.status": "status: {{status}}", - "error.chain.retryable": "retryable: {{retryable}}", - "error.chain.responseBody": "response body:\n{{body}}", - "error.chain.didYouMean": "did you mean: {{suggestions}}", - "error.chain.modelNotFound": "model not found: {{provider}}/{{model}}", - "error.chain.checkConfig": "check your config (openscience.json) provider/model names", - "error.chain.mcpFailed": 'MCP server "{{name}}" failed. note, OpenScience does not support MCP authentication yet.', - "error.chain.providerAuthFailed": "provider authentication failed ({{provider}}): {{message}}", + "error.chain.status": "Status: {{status}}", + "error.chain.retryable": "Retryable: {{retryable}}", + "error.chain.responseBody": "Response body:\n{{body}}", + "error.chain.didYouMean": "Did you mean: {{suggestions}}", + "error.chain.modelNotFound": "Model not found: {{provider}}/{{model}}", + "error.chain.checkConfig": "Check your config (openscience.json) provider/model names", + "error.chain.mcpFailed": + 'MCP server "{{name}}" failed. Note that OpenScience does not support MCP authentication yet.', + "error.chain.providerAuthFailed": "Provider authentication failed ({{provider}}): {{message}}", "error.chain.providerInitFailed": - 'failed to initialize provider "{{provider}}". check credentials and configuration.', - "error.chain.configJsonInvalid": "config file at {{path}} is not valid JSON(C)", - "error.chain.configJsonInvalidWithMessage": "config file at {{path}} is not valid JSON(C): {{message}}", + 'Failed to initialize provider "{{provider}}". Check credentials and configuration.', + "error.chain.configJsonInvalid": "Config file at {{path}} is not valid JSON(C)", + "error.chain.configJsonInvalidWithMessage": "Config file at {{path}} is not valid JSON(C): {{message}}", "error.chain.configDirectoryTypo": - 'directory "{{dir}}" in {{path}} is not valid. rename the directory to "{{suggestion}}" or remove it. this is a common typo.', - "error.chain.configFrontmatterError": "failed to parse frontmatter in {{path}}:\n{{message}}", - "error.chain.configInvalid": "config file at {{path}} is invalid", - "error.chain.configInvalidWithMessage": "config file at {{path}} is invalid: {{message}}", + 'Directory "{{dir}}" in {{path}} is not valid. Rename the directory to "{{suggestion}}" or remove it. This is a common typo.', + "error.chain.configFrontmatterError": "Failed to parse frontmatter in {{path}}:\n{{message}}", + "error.chain.configInvalid": "Config file at {{path}} is invalid", + "error.chain.configInvalidWithMessage": "Config file at {{path}} is invalid: {{message}}", - "notification.permission.title": "permission required", + "notification.permission.title": "Permission required", "notification.permission.description": "{{sessionTitle}} in {{projectName}} needs permission", - "notification.question.title": "question", + "notification.question.title": "Question", "notification.question.description": "{{sessionTitle}} in {{projectName}} has a question", - "notification.action.goToSession": "go to session", + "notification.action.goToSession": "Go to session", - "notification.session.responseReady.title": "response ready", - "notification.session.error.title": "session error", - "notification.session.error.fallbackDescription": "an error occurred", + "notification.session.responseReady.title": "Response ready", + "notification.session.error.title": "Session error", + "notification.session.error.fallbackDescription": "An error occurred", - "home.recentProjects": "recent projects", - "home.empty.title": "no recent projects", - "home.empty.description": "get started by opening a local project", + "home.recentProjects": "Recent projects", + "home.empty.title": "No recent projects", + "home.empty.description": "Get started by opening a local project", - "session.tab.session": "session", - "session.tab.review": "review", - "session.tab.context": "context", - "session.panel.reviewAndFiles": "review and files", + "session.tab.session": "Session", + "session.tab.review": "Review", + "session.tab.context": "Context", + "session.panel.reviewAndFiles": "Review and files", "session.review.filesChanged": "{{count}} files changed", - "session.review.change.one": "change", - "session.review.change.other": "changes", - "session.review.loadingChanges": "loading changes...", - "session.review.empty": "no changes in this session yet", - "session.review.noChanges": "no changes", - - "session.files.selectToOpen": "select a file to open", - "session.files.all": "all files", - - "session.messages.renderEarlier": "render earlier messages", - "session.messages.loadingEarlier": "loading earlier messages...", - "session.messages.loadEarlier": "load earlier messages", - "session.messages.loading": "loading messages...", - "session.messages.jumpToLatest": "jump to latest", - - "session.context.addToContext": "add {{selection}} to context", - - "session.new.worktree.main": "main branch", - "session.new.worktree.mainWithBranch": "main branch ({{branch}})", - "session.new.worktree.create": "create new worktree", - "session.new.lastModified": "last modified", - - "session.header.search.placeholder": "search {{project}}", - "session.header.searchFiles": "search files", - - "status.popover.trigger": "status", - "status.popover.ariaLabel": "server configurations", - "status.popover.tab.servers": "servers", + "session.review.change.one": "Change", + "session.review.change.other": "Changes", + "session.review.loadingChanges": "Loading changes…", + "session.review.empty": "No changes in this session yet", + "session.review.noChanges": "No changes", + + "session.files.selectToOpen": "Select a file to open", + "session.files.all": "All files", + + "session.messages.renderEarlier": "Render earlier messages", + "session.messages.loadingEarlier": "Loading earlier messages…", + "session.messages.loadEarlier": "Load earlier messages", + "session.messages.loading": "Loading messages…", + "session.messages.jumpToLatest": "Jump to latest", + + "session.context.addToContext": "Add {{selection}} to context", + + "session.new.worktree.main": "Main branch", + "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", + "session.new.worktree.create": "Create new worktree", + "session.new.lastModified": "Last modified", + + "session.header.search.placeholder": "Search {{project}}", + "session.header.searchFiles": "Search files", + + "status.popover.trigger": "Status", + "status.popover.ariaLabel": "Server configurations", + "status.popover.tab.servers": "Servers", "status.popover.tab.mcp": "MCP", "status.popover.tab.lsp": "LSP", - "status.popover.tab.plugins": "plugins", - "status.popover.action.manageServers": "manage servers", + "status.popover.tab.plugins": "Plugins", + "status.popover.action.manageServers": "Manage Servers", - "session.share.popover.title": "publish on web", + "session.share.popover.title": "Publish on web", "session.share.popover.description.shared": - "this session is public on the web. it is accessible to anyone with the link.", + "This session is public on the web. It is accessible to anyone with the link.", "session.share.popover.description.unshared": - "share session publicly on the web. it will be accessible to anyone with the link.", - "session.share.action.share": "share", - "session.share.action.publish": "publish", - "session.share.action.publishing": "publishing...", - "session.share.action.unpublish": "unpublish", - "session.share.action.unpublishing": "unpublishing...", - "session.share.action.view": "view", - "session.share.copy.copied": "copied", - "session.share.copy.copyLink": "copy link", - - "lsp.tooltip.none": "no LSP servers", + "Share session publicly on the web. It will be accessible to anyone with the link.", + "session.share.action.share": "Share", + "session.share.action.publish": "Publish", + "session.share.action.publishing": "Publishing...", + "session.share.action.unpublish": "Unpublish", + "session.share.action.unpublishing": "Unpublishing...", + "session.share.action.view": "View", + "session.share.copy.copied": "Copied", + "session.share.copy.copyLink": "Copy link", + + "lsp.tooltip.none": "No LSP servers", "lsp.label.connected": "{{count}} LSP", - "prompt.loading": "loading prompt...", - "terminal.loading": "loading terminal...", - "terminal.title": "terminal", - "terminal.title.numbered": "terminal {{number}}", - "terminal.close": "close terminal", - "terminal.connectionLost.title": "connection lost", + "prompt.loading": "Loading prompt…", + "terminal.loading": "Loading terminal...", + "terminal.title": "Terminal", + "terminal.title.numbered": "Terminal {{number}}", + "terminal.close": "Close terminal", + "terminal.connectionLost.title": "Connection lost", "terminal.connectionLost.description": - "the terminal connection was interrupted. this can happen when the server restarts.", + "The terminal connection was interrupted. This can happen when the server restarts.", - "common.closeTab": "close tab", - "common.dismiss": "dismiss", - "common.requestFailed": "request failed", - "common.moreOptions": "more options", + "common.closeTab": "Close tab", + "common.dismiss": "Dismiss", + "common.requestFailed": "Request failed", + "common.moreOptions": "More options", "common.learnMore": "Learn more", - "common.rename": "rename", - "common.reset": "reset", - "common.archive": "archive", - "common.delete": "delete", - "common.close": "close", - "common.edit": "edit", - "common.loadMore": "load more", - "common.key.esc": "ESC", - - "sidebar.menu.toggle": "toggle menu", - "sidebar.nav.projectsAndSessions": "projects and sessions", - "sidebar.settings": "settings", - "sidebar.help": "help", - "sidebar.workspaces.enable": "enable workspaces", - "sidebar.workspaces.disable": "disable workspaces", - "sidebar.gettingStarted.title": "getting started", + "common.rename": "Rename", + "common.reset": "Reset", + "common.archive": "Archive", + "common.delete": "Delete", + "common.close": "Close", + "common.edit": "Edit", + "common.loadMore": "Load more", + "common.key.esc": "Esc", + + "sidebar.menu.toggle": "Toggle menu", + "sidebar.nav.projectsAndSessions": "Projects and sessions", + "sidebar.settings": "Settings", + "sidebar.help": "Help", + "sidebar.workspaces.enable": "Enable workspaces", + "sidebar.workspaces.disable": "Disable workspaces", + "sidebar.gettingStarted.title": "Getting started", "sidebar.gettingStarted.line1": "OpenScience includes free models so you can start immediately.", - "sidebar.gettingStarted.line2": "connect any provider to use models, inc. Claude, GPT, Gemini etc.", - "sidebar.project.recentSessions": "recent sessions", - "sidebar.project.viewAllSessions": "view all sessions", + "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", + "sidebar.project.recentSessions": "Recent sessions", + "sidebar.project.viewAllSessions": "View all sessions", "app.name.desktop": "OpenScience Desktop", - "settings.section.desktop": "desktop", - "settings.section.server": "server", - "settings.tab.general": "general", - "settings.tab.shortcuts": "shortcuts", + "settings.section.desktop": "Desktop", + "settings.section.server": "Server", + "settings.tab.general": "General", + "settings.tab.shortcuts": "Shortcuts", "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", @@ -622,119 +600,123 @@ export const dict = { "settings.general.sounds.agent.title": "Agent", "settings.general.sounds.agent.description": "Play a sound when the agent is complete or needs attention", + "settings.general.sounds.enabled.title": "Play sound effects", + "settings.general.sounds.enabled.description": "Use subtle audio cues for important work and errors", + "settings.general.sounds.volume.title": "Sound volume", + "settings.general.sounds.volume.description": "Set audio feedback independently from system volume", "settings.general.sounds.permissions.title": "Permissions", "settings.general.sounds.permissions.description": "Play a sound when a permission is required", "settings.general.sounds.errors.title": "Errors", "settings.general.sounds.errors.description": "Play a sound when an error occurs", - "settings.shortcuts.title": "keyboard shortcuts", - "settings.shortcuts.reset.button": "reset to defaults", - "settings.shortcuts.reset.toast.title": "shortcuts reset", - "settings.shortcuts.reset.toast.description": "keyboard shortcuts have been reset to defaults.", - "settings.shortcuts.conflict.title": "shortcut already in use", + "settings.shortcuts.title": "Keyboard shortcuts", + "settings.shortcuts.reset.button": "Reset to defaults", + "settings.shortcuts.reset.toast.title": "Shortcuts reset", + "settings.shortcuts.reset.toast.description": "Keyboard shortcuts have been reset to defaults.", + "settings.shortcuts.conflict.title": "Shortcut already in use", "settings.shortcuts.conflict.description": "{{keybind}} is already assigned to {{titles}}.", - "settings.shortcuts.unassigned": "unassigned", - "settings.shortcuts.pressKeys": "press keys", - "settings.shortcuts.search.placeholder": "search shortcuts", - "settings.shortcuts.search.empty": "no shortcuts found", - - "settings.shortcuts.group.general": "general", - "settings.shortcuts.group.session": "session", - "settings.shortcuts.group.navigation": "navigation", - "settings.shortcuts.group.modelAndAgent": "model and agent", - "settings.shortcuts.group.terminal": "terminal", - "settings.shortcuts.group.prompt": "prompt", - - "settings.providers.title": "providers", - "settings.providers.description": "provider settings will be configurable here.", - "settings.providers.section.connected": "connected providers", - "settings.providers.connected.empty": "no connected providers", - "settings.providers.section.popular": "popular providers", - "settings.providers.tag.environment": "environment", - "settings.providers.tag.config": "config", - "settings.providers.tag.custom": "custom", - "settings.providers.tag.other": "other", - "settings.models.title": "models", - "settings.models.description": "model settings will be configurable here.", - "settings.agents.title": "agents", - "settings.agents.description": "agent settings will be configurable here.", - "settings.commands.title": "commands", - "settings.commands.description": "command settings will be configurable here.", + "settings.shortcuts.unassigned": "Unassigned", + "settings.shortcuts.pressKeys": "Press keys", + "settings.shortcuts.search.placeholder": "Search shortcuts", + "settings.shortcuts.search.empty": "No shortcuts found", + + "settings.shortcuts.group.general": "General", + "settings.shortcuts.group.session": "Session", + "settings.shortcuts.group.navigation": "Navigation", + "settings.shortcuts.group.modelAndAgent": "Model and agent", + "settings.shortcuts.group.terminal": "Terminal", + "settings.shortcuts.group.prompt": "Prompt", + + "settings.providers.title": "Providers", + "settings.providers.description": "Provider settings will be configurable here.", + "settings.providers.section.connected": "Connected providers", + "settings.providers.connected.empty": "No connected providers", + "settings.providers.section.popular": "Popular providers", + "settings.providers.tag.environment": "Environment", + "settings.providers.tag.config": "Config", + "settings.providers.tag.custom": "Custom", + "settings.providers.tag.other": "Other", + "settings.models.title": "Models", + "settings.models.description": "Model settings will be configurable here.", + "settings.agents.title": "Agents", + "settings.agents.description": "Agent settings will be configurable here.", + "settings.commands.title": "Commands", + "settings.commands.description": "Command settings will be configurable here.", "settings.mcp.title": "MCP", "settings.mcp.description": "MCP settings will be configurable here.", - "settings.permissions.title": "permissions", - "settings.permissions.description": "control what tools the server can use by default.", + "settings.permissions.title": "Permissions", + "settings.permissions.description": "Control what tools the server can use by default.", "settings.permissions.section.tools": "Tools", - "settings.permissions.toast.updateFailed.title": "failed to update permissions", + "settings.permissions.toast.updateFailed.title": "Failed to update permissions", "settings.permissions.action.allow": "Allow", "settings.permissions.action.ask": "Ask", "settings.permissions.action.deny": "Deny", - "settings.permissions.tool.read.title": "read", - "settings.permissions.tool.read.description": "reading a file (matches the file path)", - "settings.permissions.tool.edit.title": "edit", - "settings.permissions.tool.edit.description": "modify files, including edits, writes, patches, and multi-edits", - "settings.permissions.tool.glob.title": "glob", - "settings.permissions.tool.glob.description": "match files using glob patterns", - "settings.permissions.tool.grep.title": "grep", - "settings.permissions.tool.grep.description": "search file contents using regular expressions", - "settings.permissions.tool.list.title": "list", - "settings.permissions.tool.list.description": "list files within a directory", - "settings.permissions.tool.bash.title": "bash", - "settings.permissions.tool.bash.description": "run shell commands", - "settings.permissions.tool.task.title": "task", - "settings.permissions.tool.task.description": "launch sub-agents", - "settings.permissions.tool.skill.title": "skill", - "settings.permissions.tool.skill.description": "load a skill by name", + "settings.permissions.tool.read.title": "Read", + "settings.permissions.tool.read.description": "Reading a file (matches the file path)", + "settings.permissions.tool.edit.title": "Edit", + "settings.permissions.tool.edit.description": "Modify files, including edits, writes, patches, and multi-edits", + "settings.permissions.tool.glob.title": "Glob", + "settings.permissions.tool.glob.description": "Match files using glob patterns", + "settings.permissions.tool.grep.title": "Grep", + "settings.permissions.tool.grep.description": "Search file contents using regular expressions", + "settings.permissions.tool.list.title": "List", + "settings.permissions.tool.list.description": "List files within a directory", + "settings.permissions.tool.bash.title": "Bash", + "settings.permissions.tool.bash.description": "Run shell commands", + "settings.permissions.tool.task.title": "Task", + "settings.permissions.tool.task.description": "Launch sub-agents", + "settings.permissions.tool.skill.title": "Skill", + "settings.permissions.tool.skill.description": "Load a skill by name", "settings.permissions.tool.lsp.title": "LSP", - "settings.permissions.tool.lsp.description": "run language server queries", - "settings.permissions.tool.todoread.title": "todo read", - "settings.permissions.tool.todoread.description": "read the todo list", - "settings.permissions.tool.todowrite.title": "todo write", - "settings.permissions.tool.todowrite.description": "update the todo list", - "settings.permissions.tool.planwrite.title": "plan write", - "settings.permissions.tool.planwrite.description": "update the plan in the sidebar", - "settings.permissions.tool.webfetch.title": "web fetch", - "settings.permissions.tool.webfetch.description": "fetch content from a URL", - "settings.permissions.tool.websearch.title": "web search", - "settings.permissions.tool.websearch.description": "search the web", - "settings.permissions.tool.codesearch.title": "code search", - "settings.permissions.tool.codesearch.description": "search code on the web", - "settings.permissions.tool.external_directory.title": "external directory", - "settings.permissions.tool.external_directory.description": "access files outside the project directory", - "settings.permissions.tool.doom_loop.title": "doom loop", - "settings.permissions.tool.doom_loop.description": "detect repeated tool calls with identical input", - - "session.delete.failed.title": "failed to delete session", - "session.delete.title": "delete session", - "session.delete.confirm": 'delete session "{{name}}"?', - "session.delete.button": "delete session", - - "workspace.new": "new workspace", - "workspace.type.local": "local", - "workspace.type.sandbox": "sandbox", - "workspace.create.failed.title": "failed to create workspace", - "workspace.delete.failed.title": "failed to delete workspace", - "workspace.resetting.title": "resetting workspace", - "workspace.resetting.description": "this may take a minute.", - "workspace.reset.failed.title": "failed to reset workspace", - "workspace.reset.success.title": "workspace reset", - "workspace.reset.success.description": "workspace now matches the default branch.", - "workspace.error.stillPreparing": "workspace is still preparing", - "workspace.status.checking": "checking for unmerged changes...", - "workspace.status.error": "unable to verify git status.", - "workspace.status.clean": "no unmerged changes detected.", - "workspace.status.dirty": "unmerged changes detected in this workspace.", - "workspace.delete.title": "delete workspace", - "workspace.delete.confirm": 'delete workspace "{{name}}"?', - "workspace.delete.button": "delete workspace", - "workspace.reset.title": "reset workspace", - "workspace.reset.confirm": 'reset workspace "{{name}}"?', - "workspace.reset.button": "reset workspace", - "workspace.reset.archived.none": "no active sessions will be archived.", + "settings.permissions.tool.lsp.description": "Run language server queries", + "settings.permissions.tool.todoread.title": "Todo read", + "settings.permissions.tool.todoread.description": "Read the todo list", + "settings.permissions.tool.todowrite.title": "Todo write", + "settings.permissions.tool.todowrite.description": "Update the todo list", + "settings.permissions.tool.planwrite.title": "Plan write", + "settings.permissions.tool.planwrite.description": "Update the plan in the sidebar", + "settings.permissions.tool.webfetch.title": "Web fetch", + "settings.permissions.tool.webfetch.description": "Fetch content from a URL", + "settings.permissions.tool.websearch.title": "Web search", + "settings.permissions.tool.websearch.description": "Search the web", + "settings.permissions.tool.codesearch.title": "Code search", + "settings.permissions.tool.codesearch.description": "Search code on the web", + "settings.permissions.tool.external_directory.title": "External directory", + "settings.permissions.tool.external_directory.description": "Access files outside the project directory", + "settings.permissions.tool.doom_loop.title": "Doom loop", + "settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input", + + "session.delete.failed.title": "Failed to delete session", + "session.delete.title": "Delete session", + "session.delete.confirm": 'Delete session "{{name}}"?', + "session.delete.button": "Delete session", + + "workspace.new": "New workspace", + "workspace.type.local": "Local", + "workspace.type.sandbox": "Sandbox", + "workspace.create.failed.title": "Failed to create workspace", + "workspace.delete.failed.title": "Failed to delete workspace", + "workspace.resetting.title": "Resetting workspace", + "workspace.resetting.description": "This may take a minute.", + "workspace.reset.failed.title": "Failed to reset workspace", + "workspace.reset.success.title": "Workspace reset", + "workspace.reset.success.description": "Workspace now matches the default branch.", + "workspace.error.stillPreparing": "Workspace is still preparing", + "workspace.status.checking": "Checking for unmerged changes...", + "workspace.status.error": "Unable to verify git status.", + "workspace.status.clean": "No unmerged changes detected.", + "workspace.status.dirty": "Unmerged changes detected in this workspace.", + "workspace.delete.title": "Delete workspace", + "workspace.delete.confirm": 'Delete workspace "{{name}}"?', + "workspace.delete.button": "Delete workspace", + "workspace.reset.title": "Reset workspace", + "workspace.reset.confirm": 'Reset workspace "{{name}}"?', + "workspace.reset.button": "Reset workspace", + "workspace.reset.archived.none": "No active sessions will be archived.", "workspace.reset.archived.one": "1 session will be archived.", "workspace.reset.archived.many": "{{count}} sessions will be archived.", - "workspace.reset.note": "this will reset the workspace to match the default branch.", + "workspace.reset.note": "This will reset the workspace to match the default branch.", } diff --git a/frontend/workspace/src/i18n/es.ts b/frontend/workspace/src/i18n/es.ts index 11571b75..99cba723 100644 --- a/frontend/workspace/src/i18n/es.ts +++ b/frontend/workspace/src/i18n/es.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "adjunto", "prompt.placeholder.shell": "Introduce comando de shell...", - "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', + "prompt.placeholder.normal": "Describe la tarea de investigación en la que quieres trabajar…", "prompt.placeholder.summarizeComments": "Resumir comentarios…", "prompt.placeholder.summarizeComment": "Resumir comentario…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc para salir", - "prompt.example.1": "Arreglar un TODO en el código", - "prompt.example.2": "¿Cuál es el stack tecnológico de este proyecto?", - "prompt.example.3": "Arreglar pruebas rotas", - "prompt.example.4": "Explicar cómo funciona la autenticación", - "prompt.example.5": "Encontrar y arreglar vulnerabilidades de seguridad", - "prompt.example.6": "Añadir pruebas unitarias para el servicio de usuario", - "prompt.example.7": "Refactorizar esta función para que sea más legible", - "prompt.example.8": "¿Qué significa este error?", - "prompt.example.9": "Ayúdame a depurar este problema", - "prompt.example.10": "Generar documentación de API", - "prompt.example.11": "Optimizar consultas a la base de datos", - "prompt.example.12": "Añadir validación de entrada", - "prompt.example.13": "Crear un nuevo componente para...", - "prompt.example.14": "¿Cómo despliego este proyecto?", - "prompt.example.15": "Revisar mi código para mejores prácticas", - "prompt.example.16": "Añadir manejo de errores a esta función", - "prompt.example.17": "Explicar este patrón de regex", - "prompt.example.18": "Convertir esto a TypeScript", - "prompt.example.19": "Añadir logging en todo el código", - "prompt.example.20": "¿Qué dependencias están desactualizadas?", - "prompt.example.21": "Ayúdame a escribir un script de migración", - "prompt.example.22": "Implementar caché para este endpoint", - "prompt.example.23": "Añadir paginación a esta lista", - "prompt.example.24": "Crear un comando CLI para...", - "prompt.example.25": "¿Cómo funcionan las variables de entorno aquí?", - "prompt.popover.emptyResults": "Sin resultados coincidentes", "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", diff --git a/frontend/workspace/src/i18n/fr.ts b/frontend/workspace/src/i18n/fr.ts index d4e41458..63844d95 100644 --- a/frontend/workspace/src/i18n/fr.ts +++ b/frontend/workspace/src/i18n/fr.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "pièce jointe", "prompt.placeholder.shell": "Entrez une commande shell...", - "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', + "prompt.placeholder.normal": "Décrivez la tâche de recherche sur laquelle vous souhaitez travailler…", "prompt.placeholder.summarizeComments": "Résumer les commentaires…", "prompt.placeholder.summarizeComment": "Résumer le commentaire…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc pour quitter", - "prompt.example.1": "Corriger un TODO dans la base de code", - "prompt.example.2": "Quelle est la pile technique de ce projet ?", - "prompt.example.3": "Réparer les tests échoués", - "prompt.example.4": "Expliquer comment fonctionne l'authentification", - "prompt.example.5": "Trouver et corriger les vulnérabilités de sécurité", - "prompt.example.6": "Ajouter des tests unitaires pour le service utilisateur", - "prompt.example.7": "Refactoriser cette fonction pour être plus lisible", - "prompt.example.8": "Que signifie cette erreur ?", - "prompt.example.9": "Aidez-moi à déboguer ce problème", - "prompt.example.10": "Générer la documentation de l'API", - "prompt.example.11": "Optimiser les requêtes de base de données", - "prompt.example.12": "Ajouter une validation d'entrée", - "prompt.example.13": "Créer un nouveau composant pour...", - "prompt.example.14": "Comment déployer ce projet ?", - "prompt.example.15": "Vérifier mon code pour les meilleures pratiques", - "prompt.example.16": "Ajouter la gestion des erreurs à cette fonction", - "prompt.example.17": "Expliquer ce modèle regex", - "prompt.example.18": "Convertir ceci en TypeScript", - "prompt.example.19": "Ajouter des logs dans toute la base de code", - "prompt.example.20": "Quelles dépendances sont obsolètes ?", - "prompt.example.21": "Aidez-moi à écrire un script de migration", - "prompt.example.22": "Implémenter la mise en cache pour ce point de terminaison", - "prompt.example.23": "Ajouter la pagination à cette liste", - "prompt.example.24": "Créer une commande CLI pour...", - "prompt.example.25": "Comment fonctionnent les variables d'environnement ici ?", - "prompt.popover.emptyResults": "Aucun résultat correspondant", "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", diff --git a/frontend/workspace/src/i18n/ja.ts b/frontend/workspace/src/i18n/ja.ts index 73b9c70d..63f26fcc 100644 --- a/frontend/workspace/src/i18n/ja.ts +++ b/frontend/workspace/src/i18n/ja.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "添付ファイル", "prompt.placeholder.shell": "シェルコマンドを入力...", - "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', + "prompt.placeholder.normal": "取り組みたい研究課題を入力…", "prompt.placeholder.summarizeComments": "コメントを要約…", "prompt.placeholder.summarizeComment": "コメントを要約…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "escで終了", - "prompt.example.1": "コードベースのTODOを修正", - "prompt.example.2": "このプロジェクトの技術スタックは何ですか?", - "prompt.example.3": "壊れたテストを修正", - "prompt.example.4": "認証の仕組みを説明して", - "prompt.example.5": "セキュリティの脆弱性を見つけて修正", - "prompt.example.6": "ユーザーサービスのユニットテストを追加", - "prompt.example.7": "この関数を読みやすくリファクタリング", - "prompt.example.8": "このエラーはどういう意味ですか?", - "prompt.example.9": "この問題のデバッグを手伝って", - "prompt.example.10": "APIドキュメントを生成", - "prompt.example.11": "データベースクエリを最適化", - "prompt.example.12": "入力バリデーションを追加", - "prompt.example.13": "〜の新しいコンポーネントを作成", - "prompt.example.14": "このプロジェクトをデプロイするには?", - "prompt.example.15": "ベストプラクティスの観点でコードをレビュー", - "prompt.example.16": "この関数にエラーハンドリングを追加", - "prompt.example.17": "この正規表現パターンを説明して", - "prompt.example.18": "これをTypeScriptに変換", - "prompt.example.19": "コードベース全体にログを追加", - "prompt.example.20": "古い依存関係はどれですか?", - "prompt.example.21": "マイグレーションスクリプトの作成を手伝って", - "prompt.example.22": "このエンドポイントにキャッシュを実装", - "prompt.example.23": "このリストにページネーションを追加", - "prompt.example.24": "〜のCLIコマンドを作成", - "prompt.example.25": "ここでは環境変数はどう機能しますか?", - "prompt.popover.emptyResults": "一致する結果がありません", "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", diff --git a/frontend/workspace/src/i18n/ko.ts b/frontend/workspace/src/i18n/ko.ts index f3119f41..01dd61ea 100644 --- a/frontend/workspace/src/i18n/ko.ts +++ b/frontend/workspace/src/i18n/ko.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "첨부 파일", "prompt.placeholder.shell": "셸 명령어 입력...", - "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', + "prompt.placeholder.normal": "진행할 연구 과제를 설명하세요…", "prompt.placeholder.summarizeComments": "댓글 요약…", "prompt.placeholder.summarizeComment": "댓글 요약…", "prompt.mode.shell": "셸", "prompt.mode.shell.exit": "종료하려면 esc", - "prompt.example.1": "코드베이스의 TODO 수정", - "prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?", - "prompt.example.3": "고장 난 테스트 수정", - "prompt.example.4": "인증 작동 방식 설명", - "prompt.example.5": "보안 취약점 찾기 및 수정", - "prompt.example.6": "사용자 서비스에 단위 테스트 추가", - "prompt.example.7": "이 함수를 더 읽기 쉽게 리팩터링", - "prompt.example.8": "이 오류는 무엇을 의미하나요?", - "prompt.example.9": "이 문제 디버깅 도와줘", - "prompt.example.10": "API 문서 생성", - "prompt.example.11": "데이터베이스 쿼리 최적화", - "prompt.example.12": "입력 유효성 검사 추가", - "prompt.example.13": "...를 위한 새 컴포넌트 생성", - "prompt.example.14": "이 프로젝트를 어떻게 배포하나요?", - "prompt.example.15": "모범 사례를 기준으로 내 코드 검토", - "prompt.example.16": "이 함수에 오류 처리 추가", - "prompt.example.17": "이 정규식 패턴 설명", - "prompt.example.18": "이것을 TypeScript로 변환", - "prompt.example.19": "코드베이스 전체에 로깅 추가", - "prompt.example.20": "오래된 종속성은 무엇인가요?", - "prompt.example.21": "마이그레이션 스크립트 작성 도와줘", - "prompt.example.22": "이 엔드포인트에 캐싱 구현", - "prompt.example.23": "이 목록에 페이지네이션 추가", - "prompt.example.24": "...를 위한 CLI 명령어 생성", - "prompt.example.25": "여기서 환경 변수는 어떻게 작동하나요?", - "prompt.popover.emptyResults": "일치하는 결과 없음", "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", diff --git a/frontend/workspace/src/i18n/no.ts b/frontend/workspace/src/i18n/no.ts index f70e24d1..a0e06661 100644 --- a/frontend/workspace/src/i18n/no.ts +++ b/frontend/workspace/src/i18n/no.ts @@ -182,38 +182,12 @@ export const dict = { "common.attachment": "vedlegg", "prompt.placeholder.shell": "Skriv inn shell-kommando...", - "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', + "prompt.placeholder.normal": "Beskriv forskningsoppgaven du vil arbeide med…", "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", "prompt.placeholder.summarizeComment": "Oppsummer kommentar…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "ESC for å avslutte", - "prompt.example.1": "Fiks en TODO i kodebasen", - "prompt.example.2": "Hva er teknologistabelen i dette prosjektet?", - "prompt.example.3": "Fiks ødelagte tester", - "prompt.example.4": "Forklar hvordan autentisering fungerer", - "prompt.example.5": "Finn og fiks sikkerhetssårbarheter", - "prompt.example.6": "Legg til enhetstester for brukerservicen", - "prompt.example.7": "Refaktorer denne funksjonen for bedre lesbarhet", - "prompt.example.8": "Hva betyr denne feilen?", - "prompt.example.9": "Hjelp meg med å feilsøke dette problemet", - "prompt.example.10": "Generer API-dokumentasjon", - "prompt.example.11": "Optimaliser databasespørringer", - "prompt.example.12": "Legg til inputvalidering", - "prompt.example.13": "Lag en ny komponent for...", - "prompt.example.14": "Hvordan deployer jeg dette prosjektet?", - "prompt.example.15": "Gjennomgå koden min for beste praksis", - "prompt.example.16": "Legg til feilhåndtering i denne funksjonen", - "prompt.example.17": "Forklar dette regex-mønsteret", - "prompt.example.18": "Konverter dette til TypeScript", - "prompt.example.19": "Legg til logging i hele kodebasen", - "prompt.example.20": "Hvilke avhengigheter er utdaterte?", - "prompt.example.21": "Hjelp meg med å skrive et migreringsskript", - "prompt.example.22": "Implementer caching for dette endepunktet", - "prompt.example.23": "Legg til paginering i denne listen", - "prompt.example.24": "Lag en CLI-kommando for...", - "prompt.example.25": "Hvordan fungerer miljøvariabler her?", - "prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", diff --git a/frontend/workspace/src/i18n/pl.ts b/frontend/workspace/src/i18n/pl.ts index 90f4ab8b..d4004590 100644 --- a/frontend/workspace/src/i18n/pl.ts +++ b/frontend/workspace/src/i18n/pl.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "załącznik", "prompt.placeholder.shell": "Wpisz polecenie terminala...", - "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', + "prompt.placeholder.normal": "Opisz zadanie badawcze, nad którym chcesz pracować…", "prompt.placeholder.summarizeComments": "Podsumuj komentarze…", "prompt.placeholder.summarizeComment": "Podsumuj komentarz…", "prompt.mode.shell": "Terminal", "prompt.mode.shell.exit": "esc aby wyjść", - "prompt.example.1": "Napraw TODO w bazie kodu", - "prompt.example.2": "Jaki jest stos technologiczny tego projektu?", - "prompt.example.3": "Napraw zepsute testy", - "prompt.example.4": "Wyjaśnij jak działa uwierzytelnianie", - "prompt.example.5": "Znajdź i napraw luki w zabezpieczeniach", - "prompt.example.6": "Dodaj testy jednostkowe dla serwisu użytkownika", - "prompt.example.7": "Zrefaktoryzuj tę funkcję, aby była bardziej czytelna", - "prompt.example.8": "Co oznacza ten błąd?", - "prompt.example.9": "Pomóż mi zdebugować ten problem", - "prompt.example.10": "Wygeneruj dokumentację API", - "prompt.example.11": "Zoptymalizuj zapytania do bazy danych", - "prompt.example.12": "Dodaj walidację danych wejściowych", - "prompt.example.13": "Utwórz nowy komponent dla...", - "prompt.example.14": "Jak wdrożyć ten projekt?", - "prompt.example.15": "Sprawdź mój kod pod kątem najlepszych praktyk", - "prompt.example.16": "Dodaj obsługę błędów do tej funkcję", - "prompt.example.17": "Wyjaśnij ten wzorzec regex", - "prompt.example.18": "Przekonwertuj to na TypeScript", - "prompt.example.19": "Dodaj logowanie w całej bazie kodu", - "prompt.example.20": "Które zależności są przestarzałe?", - "prompt.example.21": "Pomóż mi napisać skrypt migracyjny", - "prompt.example.22": "Zaimplementuj cachowanie dla tego punktu końcowego", - "prompt.example.23": "Dodaj stronicowanie do tej listy", - "prompt.example.24": "Utwórz polecenie CLI dla...", - "prompt.example.25": "Jak działają tutaj zmienne środowiskowe?", - "prompt.popover.emptyResults": "Brak pasujących wyników", "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", diff --git a/frontend/workspace/src/i18n/ru.ts b/frontend/workspace/src/i18n/ru.ts index b0ed3114..39100c86 100644 --- a/frontend/workspace/src/i18n/ru.ts +++ b/frontend/workspace/src/i18n/ru.ts @@ -179,38 +179,12 @@ export const dict = { "common.attachment": "вложение", "prompt.placeholder.shell": "Введите команду оболочки...", - "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', + "prompt.placeholder.normal": "Опишите исследовательскую задачу, над которой хотите работать…", "prompt.placeholder.summarizeComments": "Суммировать комментарии…", "prompt.placeholder.summarizeComment": "Суммировать комментарий…", "prompt.mode.shell": "Оболочка", "prompt.mode.shell.exit": "esc для выхода", - "prompt.example.1": "Исправить TODO в коде", - "prompt.example.2": "Какой технологический стек этого проекта?", - "prompt.example.3": "Исправить сломанные тесты", - "prompt.example.4": "Объясни как работает аутентификация", - "prompt.example.5": "Найти и исправить уязвимости безопасности", - "prompt.example.6": "Добавить юнит-тесты для сервиса пользователя", - "prompt.example.7": "Рефакторить эту функцию для лучшей читаемости", - "prompt.example.8": "Что означает эта ошибка?", - "prompt.example.9": "Помоги мне отладить эту проблему", - "prompt.example.10": "Сгенерировать документацию API", - "prompt.example.11": "Оптимизировать запросы к базе данных", - "prompt.example.12": "Добавить валидацию ввода", - "prompt.example.13": "Создать новый компонент для...", - "prompt.example.14": "Как развернуть этот проект?", - "prompt.example.15": "Проверь мой код на лучшие практики", - "prompt.example.16": "Добавить обработку ошибок в эту функцию", - "prompt.example.17": "Объясни этот паттерн regex", - "prompt.example.18": "Конвертировать это в TypeScript", - "prompt.example.19": "Добавить логирование по всему проекту", - "prompt.example.20": "Какие зависимости устарели?", - "prompt.example.21": "Помоги написать скрипт миграции", - "prompt.example.22": "Реализовать кэширование для этой конечной точки", - "prompt.example.23": "Добавить пагинацию в этот список", - "prompt.example.24": "Создать CLI команду для...", - "prompt.example.25": "Как работают переменные окружения здесь?", - "prompt.popover.emptyResults": "Нет совпадений", "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", diff --git a/frontend/workspace/src/i18n/th.ts b/frontend/workspace/src/i18n/th.ts index 113607a4..128f754f 100644 --- a/frontend/workspace/src/i18n/th.ts +++ b/frontend/workspace/src/i18n/th.ts @@ -180,38 +180,12 @@ export const dict = { "common.attachment": "ไฟล์แนบ", "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...", - "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"', + "prompt.placeholder.normal": "อธิบายงานวิจัยที่คุณต้องการทำ…", "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…", "prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…", "prompt.mode.shell": "เชลล์", "prompt.mode.shell.exit": "กด esc เพื่อออก", - "prompt.example.1": "แก้ไข TODO ในโค้ดเบส", - "prompt.example.2": "เทคโนโลยีของโปรเจกต์นี้คืออะไร?", - "prompt.example.3": "แก้ไขการทดสอบที่เสีย", - "prompt.example.4": "อธิบายวิธีการทำงานของการตรวจสอบสิทธิ์", - "prompt.example.5": "ค้นหาและแก้ไขช่องโหว่ความปลอดภัย", - "prompt.example.6": "เพิ่มการทดสอบหน่วยสำหรับบริการผู้ใช้", - "prompt.example.7": "ปรับโครงสร้างฟังก์ชันนี้ให้อ่านง่ายขึ้น", - "prompt.example.8": "ข้อผิดพลาดนี้หมายความว่าอะไร?", - "prompt.example.9": "ช่วยฉันดีบักปัญหานี้", - "prompt.example.10": "สร้างเอกสาร API", - "prompt.example.11": "ปรับปรุงการสืบค้นฐานข้อมูล", - "prompt.example.12": "เพิ่มการตรวจสอบข้อมูลนำเข้า", - "prompt.example.13": "สร้างคอมโพเนนต์ใหม่สำหรับ...", - "prompt.example.14": "ฉันจะทำให้โปรเจกต์นี้ทำงานได้อย่างไร?", - "prompt.example.15": "ตรวจสอบโค้ดของฉันเพื่อแนวทางปฏิบัติที่ดีที่สุด", - "prompt.example.16": "เพิ่มการจัดการข้อผิดพลาดในฟังก์ชันนี้", - "prompt.example.17": "อธิบายรูปแบบ regex นี้", - "prompt.example.18": "แปลงสิ่งนี้เป็น TypeScript", - "prompt.example.19": "เพิ่มการบันทึกทั่วทั้งโค้ดเบส", - "prompt.example.20": "มีการพึ่งพาอะไรที่ล้าสมัยอยู่?", - "prompt.example.21": "ช่วยฉันเขียนสคริปต์การย้ายข้อมูล", - "prompt.example.22": "ใช้งานแคชสำหรับจุดสิ้นสุดนี้", - "prompt.example.23": "เพิ่มการแบ่งหน้าในรายการนี้", - "prompt.example.24": "สร้างคำสั่ง CLI สำหรับ...", - "prompt.example.25": "ตัวแปรสภาพแวดล้อมทำงานอย่างไรที่นี่?", - "prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน", "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", diff --git a/frontend/workspace/src/i18n/zh.ts b/frontend/workspace/src/i18n/zh.ts index a2de7745..2cb55993 100644 --- a/frontend/workspace/src/i18n/zh.ts +++ b/frontend/workspace/src/i18n/zh.ts @@ -182,38 +182,12 @@ export const dict = { "common.attachment": "附件", "prompt.placeholder.shell": "输入 shell 命令...", - "prompt.placeholder.normal": '随便问点什么... "{{example}}"', + "prompt.placeholder.normal": "描述你想开展的研究任务…", "prompt.placeholder.summarizeComments": "总结评论…", "prompt.placeholder.summarizeComment": "总结该评论…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "按 esc 退出", - "prompt.example.1": "修复代码库中的一个 TODO", - "prompt.example.2": "这个项目的技术栈是什么?", - "prompt.example.3": "修复失败的测试", - "prompt.example.4": "解释认证是如何工作的", - "prompt.example.5": "查找并修复安全漏洞", - "prompt.example.6": "为用户服务添加单元测试", - "prompt.example.7": "重构这个函数,让它更易读", - "prompt.example.8": "这个错误是什么意思?", - "prompt.example.9": "帮我调试这个问题", - "prompt.example.10": "生成 API 文档", - "prompt.example.11": "优化数据库查询", - "prompt.example.12": "添加输入校验", - "prompt.example.13": "创建一个新的组件用于...", - "prompt.example.14": "我该如何部署这个项目?", - "prompt.example.15": "审查我的代码并给出最佳实践建议", - "prompt.example.16": "为这个函数添加错误处理", - "prompt.example.17": "解释这个正则表达式", - "prompt.example.18": "把它转换成 TypeScript", - "prompt.example.19": "在整个代码库中添加日志", - "prompt.example.20": "哪些依赖已经过期?", - "prompt.example.21": "帮我写一个迁移脚本", - "prompt.example.22": "为这个接口实现缓存", - "prompt.example.23": "给这个列表添加分页", - "prompt.example.24": "创建一个 CLI 命令用于...", - "prompt.example.25": "这里的环境变量是怎么工作的?", - "prompt.popover.emptyResults": "没有匹配的结果", "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", diff --git a/frontend/workspace/src/i18n/zht.ts b/frontend/workspace/src/i18n/zht.ts index 7f5e19b9..2315f7bd 100644 --- a/frontend/workspace/src/i18n/zht.ts +++ b/frontend/workspace/src/i18n/zht.ts @@ -182,38 +182,12 @@ export const dict = { "common.attachment": "附件", "prompt.placeholder.shell": "輸入 shell 命令...", - "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', + "prompt.placeholder.normal": "描述你想進行的研究任務…", "prompt.placeholder.summarizeComments": "摘要評論…", "prompt.placeholder.summarizeComment": "摘要這則評論…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "按 esc 退出", - "prompt.example.1": "修復程式碼庫中的一個 TODO", - "prompt.example.2": "這個專案的技術堆疊是什麼?", - "prompt.example.3": "修復失敗的測試", - "prompt.example.4": "解釋驗證是如何運作的", - "prompt.example.5": "尋找並修復安全漏洞", - "prompt.example.6": "為使用者服務新增單元測試", - "prompt.example.7": "重構這個函式,讓它更易讀", - "prompt.example.8": "這個錯誤是什麼意思?", - "prompt.example.9": "幫我偵錯這個問題", - "prompt.example.10": "產生 API 文件", - "prompt.example.11": "最佳化資料庫查詢", - "prompt.example.12": "新增輸入驗證", - "prompt.example.13": "建立一個新的元件用於...", - "prompt.example.14": "我該如何部署這個專案?", - "prompt.example.15": "審查我的程式碼並給出最佳實務建議", - "prompt.example.16": "為這個函式新增錯誤處理", - "prompt.example.17": "解釋這個正規表示式", - "prompt.example.18": "把它轉換成 TypeScript", - "prompt.example.19": "在整個程式碼庫中新增日誌", - "prompt.example.20": "哪些相依性已經過期?", - "prompt.example.21": "幫我寫一個遷移腳本", - "prompt.example.22": "為這個端點實作快取", - "prompt.example.23": "給這個清單新增分頁", - "prompt.example.24": "建立一個 CLI 命令用於...", - "prompt.example.25": "這裡的環境變數是怎麼運作的?", - "prompt.popover.emptyResults": "沒有符合的結果", "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", diff --git a/frontend/workspace/src/index.css b/frontend/workspace/src/index.css index c9b46fbc..8323ed83 100644 --- a/frontend/workspace/src/index.css +++ b/frontend/workspace/src/index.css @@ -14,7 +14,10 @@ body { font-family: var(--font-family-sans); font-feature-settings: var(--font-family-sans--font-feature-settings, normal); + font-kerning: normal; font-optical-sizing: auto; + font-synthesis: none; + font-variant-ligatures: common-ligatures contextual; -webkit-font-smoothing: antialiased; } @@ -25,6 +28,12 @@ font: inherit; } + :where(a, button, [role="link"]) { + text-decoration-skip-ink: auto; + text-decoration-thickness: from-font; + text-underline-offset: 0.18em; + } + h1, h2, h3, @@ -34,7 +43,7 @@ font-family: var(--font-family-sans); font-feature-settings: var(--font-family-sans--font-feature-settings, normal); font-optical-sizing: auto; - font-weight: 550; + font-weight: var(--font-weight-emphasis); letter-spacing: -0.011em; line-height: 1.18; color: var(--text-strong, var(--color-text)); @@ -42,7 +51,7 @@ } h1 { - font-weight: 600; + font-weight: var(--font-weight-emphasis); letter-spacing: -0.016em; } } @@ -53,10 +62,13 @@ */ .prose-editorial { font-family: var(--font-family-serif); + font-feature-settings: var(--font-family-serif--font-feature-settings, normal); font-size: var(--font-size-large, 15px); line-height: 1.7; color: var(--text-base, var(--color-text)); max-width: 68ch; + font-variant-numeric: oldstyle-nums proportional-nums; + text-wrap: pretty; } .prose-editorial h1, diff --git a/frontend/workspace/src/manuscript/ManuscriptWorkbench.tsx b/frontend/workspace/src/manuscript/ManuscriptWorkbench.tsx index dcbf6070..72a02816 100644 --- a/frontend/workspace/src/manuscript/ManuscriptWorkbench.tsx +++ b/frontend/workspace/src/manuscript/ManuscriptWorkbench.tsx @@ -257,7 +257,7 @@ export function ManuscriptWorkbench(props: { "flex-shrink": 0, }} > - MANUSCRIPT + Manuscript live source + preview @@ -449,7 +449,7 @@ export function ManuscriptWorkbench(props: { background: "var(--color-bg)", }} > - +