From e8b21f5c000ee62f401fe958676c7b52a0326819 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 17 Oct 2026 13:04:19 -0700 Subject: [PATCH 1/2] feat: share presets and import versioned preset files --- .changeset/document-preset-sharing.md | 5 + .changeset/shared-preset-library.md | 7 + apps/docs/content/docs/presets/index.mdx | 2 +- apps/docs/content/docs/presets/use.mdx | 43 ++- .../src/api/__tests__/preset-sharing.test.ts | 229 ++++++++++++++ .../src/api/__tests__/presets-route.test.ts | 6 +- apps/server/api/src/api/models.ts | 6 + apps/server/api/src/api/presets.route.ts | 103 ++++++- apps/server/api/src/api/ssh-presets.ts | 6 + .../src/api/ssh/__tests__/ssh-routes.test.ts | 9 +- .../restart-preset-swap.route.test.ts | 14 +- .../__tests__/backup-cli-subprocess.test.ts | 9 +- apps/server/api/src/db/migrate.ts | 2 + .../src/db/migrations/0057-preset-sharing.ts | 26 ++ .../__tests__/presets-by-harness.test.ts | 8 +- .../repositories/preset-shares.repository.ts | 39 +++ .../src/db/repositories/presets.repository.ts | 21 ++ .../db/repositories/ssh-presets.repository.ts | 25 +- apps/server/api/src/db/types/index.ts | 3 + .../src/db/types/preset-shares.db-types.ts | 8 + apps/server/api/src/lib/preset-access.ts | 51 ++++ apps/server/api/src/lib/preset-name-error.ts | 11 + .../subshell-manager.service.test.ts | 2 +- .../api/src/services/backups/database.ts | 2 +- .../src/services/preset-sharing.service.ts | 40 +++ .../api/src/services/ssh-presets.service.ts | 5 +- .../src/services/subshell-manager.service.ts | 24 +- .../api/src/services/subshells.service.ts | 37 ++- apps/server/web/AGENTS.md | 20 ++ .../__tests__/network-plugin-card.test.tsx | 6 +- .../connect/ssh-preset-controls.tsx | 3 +- apps/server/web/src/components/fact-list.tsx | 4 +- .../__tests__/import-preset-dialog.test.tsx | 128 ++++++++ .../__tests__/preset-sharing-dialog.test.tsx | 78 +++++ .../__tests__/ssh-preset-editor.test.tsx | 6 +- .../presets/agent-preset-library.tsx | 44 ++- .../presets/import-preset-dialog.tsx | 284 ++++++++++++++++++ .../src/components/presets/preset-details.tsx | 69 +++++ .../presets/preset-sharing-dialog.tsx | 50 +++ .../components/presets/preset-table-row.tsx | 14 +- .../components/presets/ssh-preset-editor.tsx | 36 ++- .../components/presets/ssh-preset-library.tsx | 57 +++- .../src/components/sharing-dialog-core.tsx | 68 +++-- .../src/components/subshell-actions-menu.tsx | 3 +- .../launch-command-preview.tsx | 3 +- .../subshell-picker/new-subshell-form.tsx | 3 +- .../src/components/switch-preset-dialog.tsx | 8 +- .../server/web/src/hooks/use-preset-shares.ts | 27 ++ .../src/lib/__tests__/preset-errors.test.ts | 26 ++ .../web/src/lib/__tests__/preset-file.test.ts | 138 +++++++++ .../web/src/lib/__tests__/preset-form.test.ts | 26 +- apps/server/web/src/lib/preset-access.ts | 10 + apps/server/web/src/lib/preset-command.ts | 14 +- apps/server/web/src/lib/preset-errors.ts | 13 + apps/server/web/src/lib/preset-file.ts | 206 +++++++++++++ apps/server/web/src/lib/preset-form.ts | 51 +++- .../routes/__tests__/presets-page.test.tsx | 40 +++ apps/server/web/src/routes/presets.tsx | 21 +- apps/server/web/src/routes/presets_.$id.tsx | 19 ++ apps/server/web/src/types/preset.ts | 13 +- docs/design-system.md | 16 +- docs/security.md | 33 ++ .../node-admin/src/components/fact-list.tsx | 2 +- 63 files changed, 2139 insertions(+), 143 deletions(-) create mode 100644 .changeset/document-preset-sharing.md create mode 100644 .changeset/shared-preset-library.md create mode 100644 apps/server/api/src/api/__tests__/preset-sharing.test.ts create mode 100644 apps/server/api/src/db/migrations/0057-preset-sharing.ts create mode 100644 apps/server/api/src/db/repositories/preset-shares.repository.ts create mode 100644 apps/server/api/src/db/types/preset-shares.db-types.ts create mode 100644 apps/server/api/src/lib/preset-access.ts create mode 100644 apps/server/api/src/lib/preset-name-error.ts create mode 100644 apps/server/api/src/services/preset-sharing.service.ts create mode 100644 apps/server/web/src/components/presets/__tests__/import-preset-dialog.test.tsx create mode 100644 apps/server/web/src/components/presets/__tests__/preset-sharing-dialog.test.tsx create mode 100644 apps/server/web/src/components/presets/import-preset-dialog.tsx create mode 100644 apps/server/web/src/components/presets/preset-details.tsx create mode 100644 apps/server/web/src/components/presets/preset-sharing-dialog.tsx create mode 100644 apps/server/web/src/hooks/use-preset-shares.ts create mode 100644 apps/server/web/src/lib/__tests__/preset-errors.test.ts create mode 100644 apps/server/web/src/lib/__tests__/preset-file.test.ts create mode 100644 apps/server/web/src/lib/preset-access.ts create mode 100644 apps/server/web/src/lib/preset-errors.ts create mode 100644 apps/server/web/src/lib/preset-file.ts diff --git a/.changeset/document-preset-sharing.md b/.changeset/document-preset-sharing.md new file mode 100644 index 000000000..18e4c5add --- /dev/null +++ b/.changeset/document-preset-sharing.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": patch +--- + +Document sharing agent and SSH presets, access levels, secret disclosure, revocation behavior, and version 1 preset export/import. diff --git a/.changeset/shared-preset-library.md b/.changeset/shared-preset-library.md new file mode 100644 index 000000000..678e73ba7 --- /dev/null +++ b/.changeset/shared-preset-library.md @@ -0,0 +1,7 @@ +--- +"@internal/server": minor +--- + +Share agent and SSH presets with individual users or everyone on the instance. View access allows inspection, use, and cloning; Edit access also allows changes. Sharing and deletion remain owner-only, and using a shared preset still requires node and SSH permissions. + +Export presets as versioned .preset JSON files and import validated files as new private copies, with name and node choices available before saving. diff --git a/apps/docs/content/docs/presets/index.mdx b/apps/docs/content/docs/presets/index.mdx index 25af2f44c..950c9dd30 100644 --- a/apps/docs/content/docs/presets/index.mdx +++ b/apps/docs/content/docs/presets/index.mdx @@ -11,7 +11,7 @@ An agent preset records the agent, command flags, environment values, and restar Open **Presets** in the dashboard sidebar and choose **Agent** or **SSH**. Agent presets are grouped by agent. SSH presets list their destination, connecting node, and authentication choice. The **New preset** menu offers **New agent preset** and **New SSH preset** from either tab. See [Connect over SSH](/guides/ssh#ssh-presets-and-recent-destinations) to create and use an SSH preset. -Presets belong to your account. Saving an agent preset doesn't install or authenticate the agent CLI; those steps happen on the machine where the session runs. +Presets are private to your account until you [share them](/presets/use#share-a-preset). You can share agent and SSH presets with individual people or everyone signed in to the instance. Saving an agent preset doesn't install or authenticate the agent CLI; those steps happen on the machine where the session runs. ## Choose a guide diff --git a/apps/docs/content/docs/presets/use.mdx b/apps/docs/content/docs/presets/use.mdx index 0a46a3c16..f2f16aa63 100644 --- a/apps/docs/content/docs/presets/use.mdx +++ b/apps/docs/content/docs/presets/use.mdx @@ -16,9 +16,46 @@ For SSH presets, select **SSH** on the **Presets** page. A preset’s **Connect* Changing a field in the launch form changes that launch; it doesn't update the saved preset. See [Launch a subshell](/guides/launch). +## Share a preset + +You must own a preset to manage its sharing. In the dashboard sidebar, open **Presets**, choose **Agent** or **SSH**, and open the preset's **⋯** menu. + +1. Choose **Share preset**. +2. Select a person or **Everyone**, then choose **View** or **Edit** and select **Add**. +3. Select **Save** to apply the grants. Save stays disabled until something changes; Cancel discards your edits. + +**Everyone** means authenticated users of this instance, not a public link. View access lets someone inspect, use, and clone the preset. Edit access also lets them change its saved settings. Only the owner can share or delete it, including when another user is an administrator. + +Sharing an agent preset exposes all of its saved settings, including environment variables and any secrets stored in them. Editors can change the configuration used by future launches and restarts. Only grant access to people you trust with those settings. + +Sharing an SSH preset exposes its destination and connection choices. It doesn't grant access to the connecting node, the node providing keys, or destination approvals. Those checks still apply when someone connects. + +Shared presets appear in the recipient's Presets page and launch pickers, with the owner's name. A viewer can choose **View preset** to inspect the settings or **Clone preset** to make a private copy. + +To revoke access, open **Share preset**, select **Remove** beside the grant, and save. Remove an Everyone grant too if it still gives that person access. Revocation prevents new use of the preset; it doesn't stop running subshells, erase copies, or undo information already disclosed. A subshell using a revoked agent preset needs another preset, or no preset, before it can restart. + +Machine tokens don't inherit preset sharing. MCP helpers can still use only their owner's presets. + +## Export or import + +To export an agent or SSH preset you can view, open **Presets** in the dashboard sidebar, open its **⋯** menu, and choose **Export preset**. Your browser downloads a JSON file named after the preset with a `.preset` extension. Characters that aren't safe in a filename are replaced with underscores. + +Exports include all saved settings. An agent preset can include environment secrets and prompt text, so treat its exported file accordingly. Ownership, sharing grants, and timestamps aren't exported. SSH exports include connection choices and public key fingerprints, never private keys, passwords, or destination approvals. + +To import a file: + +1. Open **Presets**, select **New preset**, and choose **Import preset**. +2. Use **Preset file** to choose the file through your browser. +3. Review its name and settings. Choose nodes on the current instance if the original nodes aren't available. Use a new name if you already own a preset with that name. +4. Select **Import preset**. + +Import creates a new private preset without starting a subshell. The dialog validates the file before enabling Import, and the server checks permissions, agent availability, name conflicts, and applicable SSH destination settings before saving. A rejected import stays open so you can correct it. + +Preset files use the `subshell-preset` format with `version: 1` and a `preset` object whose `kind` is `agent` or `ssh`. The importer rejects malformed files, unknown fields, unsupported versions, and files larger than 1 MiB. It doesn't overwrite an existing preset or grant access to the nodes named in the file. + ## Edit or clone -In the dashboard sidebar, open **Presets**, open a preset's **⋯** menu, and choose **Edit**. Change its values and select **Save**. Existing processes keep their launch-time configuration. +If you own a preset or have Edit access, open **Presets** in the dashboard sidebar, open its **⋯** menu, and choose **Edit**. Change its values and select **Save**. Existing processes keep their launch-time configuration. To keep the original, open the preset row's **⋯** menu and choose **Clone preset**. Give the copy a distinct name and review its values before saving. The agent stays the same; create a new preset to choose a different agent. @@ -52,3 +89,7 @@ The agent that creates a helper must clean it up when the work is done. Follow [ ## Next steps Build task text with [Reusable Prompts](/prompts) or review [MCP tool reference](/mcp/tools). + +If an exported preset refers to a deleted node, you can still export it. Choose +replacement nodes in the import dialog before importing; SSH key selections and +node permissions are checked again when you connect. diff --git a/apps/server/api/src/api/__tests__/preset-sharing.test.ts b/apps/server/api/src/api/__tests__/preset-sharing.test.ts new file mode 100644 index 000000000..0bd9cfce6 --- /dev/null +++ b/apps/server/api/src/api/__tests__/preset-sharing.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { presetRoutes } from "@/api/presets.route.js"; +import { db } from "@/db/index.js"; +import { PresetSharesRepository } from "@/db/repositories/preset-shares.repository.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { presetAccess } from "@/lib/preset-access.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { FakeNodeLauncher } from "@/services/__tests__/helpers/node-fakes.js"; +import { seedPreset } from "@/services/__tests__/helpers/seed-preset.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { SubshellManagerService } from "@/services/subshell-manager.service.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "./helpers/auth-tables.js"; + +type PresetView = { id: string; access: string; ownerName: string; envJson: string | null }; +async function json(response: Response): Promise { + return (await response.json()) as T; +} + +const app = new Elysia().use(errorHandlerPlugin).use(presetRoutes); +const agents = new PresetsRepository(db); +const ssh = new SshPresetsRepository(db); +const grants = new PresetSharesRepository(db); +type Person = { id: string; cookie: string }; +const people: Person[] = []; +let owner: Person; +let viewer: Person; +let stranger: Person; +let key: string; +let pane: string; +function request(person: Person, path: string, method = "GET", body?: unknown) { + return app.fetch( + authedRequest(`/api/presets${path}`, person.cookie, { + method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); +} +function share(id: string, shares: { granteeUserId: string | null; permission: "view" | "edit" }[]) { + return request(owner, `/${id}/shares`, "PUT", { shares }); +} +async function seed(kind: "agent" | "ssh") { + if (kind === "agent") + return seedPreset(agents, { + userId: owner.id, + harnessId: "terminal", + name: crypto.randomUUID(), + envJson: '{"SECRET":"visible-to-grantees"}', + }); + const id = crypto.randomUUID(); + await ssh.create({ + id, + userId: owner.id, + name: id, + description: null, + nodeId: "local", + destination: "host.example:22", + alias: null, + resolutionAlias: "host.example:22", + authentication: "password", + keyHome: null, + fingerprints: null, + forwards: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return id; +} +beforeAll(async () => { + await setupAuthTables(); + await ensureLocalNode(db); + for (let index = 0; index < 3; index++) { + const email = `preset-share-${crypto.randomUUID()}@example.test`; + const password = "Preset-sharing-test-2026!"; + const id = await new UsersRepository(db).createUser({ + email, + name: `Person ${index}`, + passwordHash: await hashPassword(password), + role: index === 2 ? "admin" : "user", + }); + people.push({ id, cookie: await signIn(email, password) }); + } + [owner, viewer, stranger] = people as [Person, Person, Person]; + pane = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: pane, + userId: viewer.id, + presetId: null, + harnessId: "terminal", + name: "Sharing test", + workingDir: "/tmp", + tmuxSocket: null, + }); + key = await issueSubshellToken(pane, viewer.id); +}); +afterAll(async () => { + await db.deleteFrom("subshells").where("id", "=", pane).execute(); + for (const person of people) await deleteUserByEmailOrId(person.id); +}); +for (const kind of ["agent", "ssh"] as const) + describe(`${kind} preset sharing`, () => { + it("keeps private rows hidden, grants view without writes, and preserves ownership on edit", async () => { + const id = await seed(kind); + expect((await request(stranger, `/${id}/shares`)).status).toBe(404); + expect( + (await json(await request(viewer, `?kind=${kind}`))).some((row: { id: string }) => row.id === id), + ).toBe(false); + expect((await share(id, [{ granteeUserId: viewer.id, permission: "view" }])).status).toBe(200); + const rows = await json(await request(viewer, `?kind=${kind}`)); + const row = rows.find((row: { id: string }) => row.id === id); + expect(row?.access).toBe("view"); + expect(row?.ownerName).toBe("Person 0"); + if (kind === "agent") expect(row?.envJson).toContain("visible-to-grantees"); + const update = + kind === "agent" + ? { name: "Edited agent" } + : { name: "Edited SSH", nodeId: "local", destination: "host.example:22", authentication: "password" }; + expect((await request(viewer, `/${id}`, "PUT", update)).status).toBe(403); + expect((await request(viewer, `/${id}`, "DELETE")).status).toBe(404); + expect((await request(viewer, `/${id}/shares`, "PUT", { shares: [] })).status).toBe(403); + await share(id, [{ granteeUserId: viewer.id, permission: "edit" }]); + const edited = await request(viewer, `/${id}`, "PUT", update); + expect(edited.status).toBe(200); + expect(await json(edited)).toMatchObject({ access: "edit", ownerName: "Person 0" }); + expect((kind === "agent" ? await agents.findById(id) : await ssh.findById(id))?.userId).toBe(owner.id); + expect((await request(viewer, `/${id}/shares`)).status).toBe(403); + expect((await request(viewer, `/${id}`, "DELETE")).status).toBe(404); + await share(id, []); + expect( + (await json(await request(viewer, `?kind=${kind}`))).some((row: { id: string }) => row.id === id), + ).toBe(false); + expect((await request(viewer, `/${id}`, "PUT", update)).status).toBe(404); + }); + it("combines Everyone and named grants, rejects unknown users atomically, and cascades deletion", async () => { + const id = await seed(kind); + expect( + ( + await share(id, [ + { granteeUserId: null, permission: "view" }, + { granteeUserId: viewer.id, permission: "edit" }, + ]) + ).status, + ).toBe(200); + const row = kind === "agent" ? await agents.findById(id) : await ssh.findById(id); + if (!row) throw new Error("Missing fixture"); + expect(await presetAccess(db, kind, row, viewer.id)).toBe("edit"); + expect(await presetAccess(db, kind, row, stranger.id)).toBe("view"); + expect(await presetAccess(db, kind, row, viewer.id, false)).toBe("none"); + expect((await share(id, [{ granteeUserId: "missing-user", permission: "edit" }])).status).toBe(400); + expect((await grants.list(kind, [id])).length).toBe(2); + expect((await request(owner, `/${id}`, "DELETE")).status).toBe(200); + expect(await grants.list(kind, [id])).toEqual([]); + }); + it("refuses bearer sharing and never lists shared rows to machine actors", async () => { + const id = await seed(kind); + await share(id, [{ granteeUserId: viewer.id, permission: "edit" }]); + for (const method of ["GET", "PUT"]) { + const response = await app.fetch( + new Request(`http://localhost:3080/api/presets/${id}/shares`, { + method, + headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, + ...(method === "PUT" ? { body: JSON.stringify({ shares: [] }) } : {}), + }), + ); + expect(response.status).toBe(403); + } + const response = await app.fetch( + new Request(`http://localhost:3080/api/presets?kind=${kind}`, { headers: { authorization: `Bearer ${key}` } }), + ); + if (kind === "ssh") expect(response.status).toBe(403); + else expect((await json(response)).some((row: { id: string }) => row.id === id)).toBe(false); + }); + }); + +it("uses a view-shared agent preset for browser launches and rechecks access on restart", async () => { + const id = await seed("agent"); + await share(id, [{ granteeUserId: viewer.id, permission: "view" }]); + const launcher = new FakeNodeLauncher("/tmp"); + const subshells = new SubshellsRepository(db); + const manager = new SubshellManagerService({ + subshells, + presets: agents, + launcher, + tokens: { issue: async () => "test-key", revoke: async () => {} }, + audit: async () => {}, + notify: async () => {}, + }); + const input = { userId: viewer.id, harnessId: "terminal", presetId: id, workingDir: "/tmp" }; + await expect(manager.createSubshell(input)).rejects.toThrow("Preset not found"); + const created = await manager.createSubshell({ ...input, allowSharedPreset: true }); + try { + expect(launcher.plans).toHaveLength(1); + await manager.restartSubshell(viewer.id, created.id); + expect(launcher.plans).toHaveLength(2); + await share(id, []); + const killsBeforeRevocation = launcher.kills.length; + await expect( + getRequestlessContext().services.subshells.restartSubshell(viewer.id, created.id, "cookie"), + ).rejects.toThrow("Preset access was removed"); + expect((await subshells.findById(created.id))?.alive).toBe(1); + await expect(manager.restartSubshell(viewer.id, created.id)).rejects.toThrow("Preset access was removed"); + expect(launcher.plans).toHaveLength(2); + expect(launcher.kills).toHaveLength(killsBeforeRevocation); + } finally { + await subshells.delete(created.id); + } +}); + +it("shared presets do not bypass node access or machine-actor restrictions", async () => { + const id = await seed("agent"); + await share(id, [{ granteeUserId: viewer.id, permission: "view" }]); + const service = getRequestlessContext().services.subshells; + const input = { + userId: viewer.id, + harnessId: "terminal", + presetId: id, + workingDir: "/tmp", + nodeId: "inaccessible-node", + crossAgent: false, + }; + await expect(service.createSubshell({ ...input, machineActor: true })).rejects.toThrow("Preset not found"); + await expect(service.createSubshell({ ...input, machineActor: false })).rejects.toThrow("Node not found"); +}); diff --git a/apps/server/api/src/api/__tests__/presets-route.test.ts b/apps/server/api/src/api/__tests__/presets-route.test.ts index 85813c11f..4313b568c 100644 --- a/apps/server/api/src/api/__tests__/presets-route.test.ts +++ b/apps/server/api/src/api/__tests__/presets-route.test.ts @@ -371,6 +371,8 @@ describe("preset writes carry the launch fields (spec 2026-09-29)", () => { // The row shape is exactly the schema's, now INCLUDING the three columns. expect(Object.keys(body).sort()).toEqual( [ + "access", + "ownerName", "configIsolation", "createdAt", "crossCommEnabled", @@ -585,7 +587,9 @@ describe("preset writes carry the launch fields (spec 2026-09-29)", () => { }), ); expect(res.status).toBe(409); - expect(((await res.json()) as { message: string }).message).toMatch(/already have a claude-code preset named/i); + const error = (await res.json()) as { message: string; metadata?: unknown; metadataSafe?: unknown }; + expect(error.message).toMatch(/already have a claude-code preset named/i); + expect(error.metadataSafe ?? error.metadata).toEqual({ reason: "duplicate_preset_name", field: "name" }); // The failed rename left both rows exactly as they were. expect((await repo.findById(second))?.name).toBe("rename-source"); expect((await repo.findById(first))?.name).toBe("rename-target"); diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index eee4fbd94..6ad113109 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -38,6 +38,12 @@ export const UserInfoSchema = t.Object({ }); export const PresetSchema = t.Object({ + ownerName: t.Optional(t.String({ description: "Preset owner display name" })), + access: t.Optional( + t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], { + description: "Viewer access; sharing and deletion remain owner-only", + }), + ), kind: t.Literal("agent", { description: "Agent preset discriminator" }), id: t.String({ description: "Preset id" }), userId: t.String({ description: "Owning user id" }), diff --git a/apps/server/api/src/api/presets.route.ts b/apps/server/api/src/api/presets.route.ts index 7cae66fda..83a1efbaf 100644 --- a/apps/server/api/src/api/presets.route.ts +++ b/apps/server/api/src/api/presets.route.ts @@ -1,9 +1,14 @@ import { getHarness, type SettingsField } from "@internal/pane-runtime"; import { joinPresetPrompt, type PresetPromptBlock, presetLaunchRequirementsMet } from "@internal/subshell-protocol"; import { Elysia, t } from "elysia"; -import { authGuard } from "@/api/auth-guard.js"; +import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { getAllHarnessIds } from "@/api/harness-utils.js"; -import { HarnessSchemaResponseSchema, PresetSchema } from "@/api/models.js"; +import { + HarnessSchemaResponseSchema, + PresetSchema, + SetSubshellSharesBodySchema, + SubshellSharesResponseSchema, +} from "@/api/models.js"; import { SshResolveRefusalViewSchema } from "@/api/ssh/ssh-views.js"; import { CreateSshPresetSchema, @@ -19,8 +24,11 @@ import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { presetAccess, presetOwnerName, withPresetAccess } from "@/lib/preset-access.js"; +import { duplicatePresetName } from "@/lib/preset-name-error.js"; import { apiModels } from "@/schema/index.js"; import { PORTABLE_MCP_LAUNCH } from "@/services/mcp-resolve.js"; +import { presetShares } from "@/services/preset-sharing.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { saveSshPreset } from "@/services/ssh-presets.service.js"; @@ -245,6 +253,54 @@ const UPDATE_PRESET_KEYS = new Set([ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) .use(apiModels) .use(authGuard) + .get( + "/:id/shares", + async ({ params, user, actor }) => { + requireCookieActor(actor, "Sign in through the dashboard to manage preset sharing."); + return presetShares(db, user.id, params.id); + }, + { + detail: { + operationId: "getPresetShares", + tags: ["presets"], + description: "List sharing grants for an owned agent or SSH preset (browser sessions only)", + }, + response: { + 200: SubshellSharesResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + }, + ) + .put( + "/:id/shares", + async ({ params, user, actor, body }) => { + requireCookieActor(actor, "Sign in through the dashboard to manage preset sharing."); + return presetShares( + db, + user.id, + params.id, + body.shares.map((entry) => ({ ...entry, granteeUserId: entry.granteeUserId ?? null })), + ); + }, + { + body: SetSubshellSharesBodySchema, + detail: { + operationId: "setPresetShares", + tags: ["presets"], + description: "Replace sharing grants for an owned agent or SSH preset (browser sessions only)", + }, + response: { + 200: SubshellSharesResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + }, + ) .post( "/", async ({ body, user, actor, status }) => { @@ -254,7 +310,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) if ("kind" in body && body.kind === "ssh") { const answer = await saveSshPreset(user.id, body); if ("refusal" in answer) return status(422, { outcome: answer.refusal }); - return answer; + return { ...answer, access: "owner" as const, ownerName: await presetOwnerName(db, user.id) }; } const badEnv = findInvalidEnvName(body.env); if (badEnv !== undefined) { @@ -309,10 +365,12 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) ...launch, })), kind: "agent" as const, + access: "owner" as const, + ownerName: await presetOwnerName(db, user.id), }; } catch (err) { if (isDuplicateName(err)) { - throw new PresetError("duplicate", `You already have a ${body.harnessId} preset named "${body.name}"`, 409); + duplicatePresetName(`You already have a ${body.harnessId} preset named "${body.name}"`); } throw err; } @@ -351,11 +409,23 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) } const sshRows = query.kind === "ssh" || query.kind === "all" - ? (await new SshPresetsRepository(db).listByUser(user.id)).map(sshPresetView) + ? await withPresetAccess( + db, + "ssh", + (await new SshPresetsRepository(db).listAccessible(user.id)).map(sshPresetView), + user.id, + ) : []; if (query.kind === "ssh") return sshRows; const repo = new PresetsRepository(db); - const rows = await repo.listByUser(user.id, query.harnessId); + const accessible = actor === "cookie" ? await repo.listAccessible(user.id) : await repo.listByUser(user.id); + const rows = await withPresetAccess( + db, + "agent", + accessible.filter((row) => !query.harnessId || row.harnessId === query.harnessId), + user.id, + actor === "cookie", + ); // A disabled or not-installed harness makes its presets unavailable: // they are not listed anywhere (cards, new-subshell pickers), and // re-enabling/installing the harness brings them back — nothing here @@ -400,7 +470,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) operationId: "listPresets", tags: ["presets"], description: - "Lists the authenticated user's presets (bearer/machine actors get envJson redacted to null; cookie sessions see full rows). Filtered to harnesses the INSTANCE store offers: installed, enabled, not broken", + "Lists owned presets and presets shared with browser viewers (bearer/machine actors get envJson redacted to null; cookie sessions see full rows). Filtered to harnesses the INSTANCE store offers: installed, enabled, not broken", }, }, ) @@ -462,12 +532,14 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) const sshRepo = new SshPresetsRepository(db); const sshExisting = await sshRepo.findById(params.id); if (sshExisting) { - if (sshExisting.userId !== user.id) throw new PresetError("not_found", "Preset not found"); + const access = await presetAccess(db, "ssh", sshExisting, user.id); + if (access === "none") throw new PresetError("not_found", "Preset not found"); + if (access === "view") throw new PresetError("forbidden", "You need edit access to change this preset.", 403); if (!("authentication" in body)) throw new PresetError("bad_request", "Send the complete SSH preset fields", 400); const answer = await saveSshPreset(user.id, body, sshExisting); if ("refusal" in answer) return status(422, { outcome: answer.refusal }); - return answer; + return { ...answer, access, ownerName: await presetOwnerName(db, sshExisting.userId) }; } if ("authentication" in body) throw new PresetError("bad_request", "A preset's kind can't be changed", 400); const badEnv = findInvalidEnvName(body.env); @@ -476,9 +548,10 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) } const repo = new PresetsRepository(db); const existing = await repo.findById(params.id); - if (!existing || existing.userId !== user.id) { - throw new PresetError("not_found", "Preset not found"); - } + if (!existing) throw new PresetError("not_found", "Preset not found"); + const access = await presetAccess(db, "agent", existing, user.id); + if (access === "none") throw new PresetError("not_found", "Preset not found"); + if (access === "view") throw new PresetError("forbidden", "You need edit access to change this preset.", 403); const name = body.name ?? existing.name; const launch = await validatePresetLaunchFields(user.id, body); const crossCommEnabled = @@ -508,12 +581,12 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) }); } catch (err) { if (isDuplicateName(err)) { - throw new PresetError("duplicate", `You already have a ${existing.harnessId} preset named "${name}"`, 409); + duplicatePresetName(`You already have a ${existing.harnessId} preset named "${name}"`); } throw err; } if (!updated) throw new PresetError("not_found", "Preset not found"); - return { ...updated, kind: "agent" as const }; + return { ...updated, kind: "agent" as const, access, ownerName: await presetOwnerName(db, existing.userId) }; }, { // Runs BEFORE validation, on the parsed (unstripped) body — the only @@ -552,7 +625,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) operationId: "updatePreset", tags: ["presets"], description: - "Updates a preset owned by the authenticated user (cookie session only); the harness is fixed at create and any field a PUT cannot apply is refused", + "Updates an owned or edit-shared preset (cookie session only); the harness is fixed at create and any field a PUT cannot apply is refused", }, }, ) diff --git a/apps/server/api/src/api/ssh-presets.ts b/apps/server/api/src/api/ssh-presets.ts index f693f35ad..a6266f17c 100644 --- a/apps/server/api/src/api/ssh-presets.ts +++ b/apps/server/api/src/api/ssh-presets.ts @@ -77,6 +77,12 @@ export const UpdateSshPresetSchema = t.Object( { additionalProperties: false }, ); export const SshPresetSchema = t.Object({ + ownerName: t.Optional(t.String({ description: "Preset owner display name" })), + access: t.Optional( + t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], { + description: "Viewer access; sharing and deletion remain owner-only", + }), + ), ...SshPresetFields, kind: t.Literal("ssh", { description: "SSH preset discriminator" }), id: t.String({ description: "Preset id" }), diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts index 2cfe208f9..1c05140b1 100644 --- a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -1240,7 +1240,10 @@ describe("SSH preset CRUD", () => { expect(created.status).toBe(200); const row = (await created.json()) as { id: string; name: string }; expect(row.name).toBe("Trimmed SSH name"); - expect((await save(body(" trimmed ssh NAME "))).status).toBe(409); + const duplicate = await save(body(" trimmed ssh NAME ")); + expect(duplicate.status).toBe(409); + const error = (await duplicate.json()) as { metadata?: unknown; metadataSafe?: unknown }; + expect(error.metadataSafe ?? error.metadata).toEqual({ reason: "duplicate_preset_name", field: "name" }); const { kind: _kind, ...update } = body(" "); const refused = await sshFetch(`/api/presets/${row.id}`, { method: "PUT", cookie: ownerCookie, body: update }); expect(refused.status).toBe(400); @@ -1474,7 +1477,7 @@ describe("SSH preset CRUD", () => { const duplicate = await save(body("ssh CANONICAL")); expect(duplicate.status).toBe(409); const changed: Record = { ...row, name: "SSH renamed" }; - const { kind, id, userId, createdAt, updatedAt, ...update } = changed; + const { kind, id, userId, createdAt, updatedAt, access, ownerName, ...update } = changed; const count = scripted.cmdsOf("ssh_resolve_config").length; scripted.detach(); const edited = await sshFetch(`/api/presets/${id}`, { method: "PUT", cookie: ownerCookie, body: update }); @@ -1521,7 +1524,7 @@ describe("SSH preset CRUD", () => { const canonical = "deploy@example.test:22"; expect(row).toMatchObject({ destination: canonical, resolutionAlias: "work" }); const listed = await sshFetch("/api/presets?kind=ssh", { cookie: ownerCookie }); - expect(await listed.json()).toContainEqual(row); + expect(await listed.json()).toEqual(expect.arrayContaining([expect.objectContaining(row)])); const launched = await launch( { node: NODE_ON, destination: row.resolutionAlias, expectedDestination: canonical }, ownerCookie, diff --git a/apps/server/api/src/api/subshells/__tests__/restart-preset-swap.route.test.ts b/apps/server/api/src/api/subshells/__tests__/restart-preset-swap.route.test.ts index 63a2e82e4..54624d4ad 100644 --- a/apps/server/api/src/api/subshells/__tests__/restart-preset-swap.route.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/restart-preset-swap.route.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { NodeCommandBody } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; - /** * The preset swap inside the restart route (spec 2026-09-23 §2). * @@ -23,6 +22,7 @@ import { Elysia } from "elysia"; import { subshellRoutes } from "@/api/subshells/index.js"; import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { PresetSharesRepository } from "@/db/repositories/preset-shares.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -250,9 +250,14 @@ describe("preset swap inside POST /api/subshells/:id/restart (spec 2026-09-23)", try { const id = await createOnLive("swap-by-grantee"); await shares.replaceForSubshell(id, [{ granteeUserId: granteeId, permission: "edit" }], ownerId); - // presetForeign is the GRANTEE's own claude-code preset — valid for them - // (validation is caller-owned), which is exactly the case whose actor - // used to be recorded as the owner. + // The caller can choose their own preset after sharing it with the + // pane owner, who must retain access for later restarts. + await new PresetSharesRepository(db).replace( + "agent", + presetForeign, + [{ granteeUserId: ownerId, permission: "view" }], + granteeId, + ); const res = await restart(id, { cookie: granteeCookie, body: { presetId: presetForeign } }); expect(res.status).toBe(200); expect(await rowPreset(id)).toBe(presetForeign); @@ -269,6 +274,7 @@ describe("preset swap inside POST /api/subshells/:id/restart (spec 2026-09-23)", expect(restartRows.length).toBe(1); expect(restartRows[0]?.actorUserId).toBe(granteeId); } finally { + await new PresetSharesRepository(db).replace("agent", presetForeign, [], granteeId); sim.detach(); } }); diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index ac33cc75c..a3cca2de4 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -398,11 +398,11 @@ test("real entry: available older upgrade snapshot restores and migrates before // strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing') ORDER BY name", ) .all(), ).toEqual([ @@ -444,6 +444,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0054-ssh-presets" }, { name: "0055-node-ssh-wide-forwards" }, { name: "0056-ssh-preset-forwards" }, + { name: "0057-preset-sharing" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index a5d4ad7e0..887eaa91b 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -56,6 +56,7 @@ import * as sshPresetsMigration from "@/db/migrations/0054-ssh-presets.js"; import * as sshWideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js"; import * as sshPresetForwardsMigration from "@/db/migrations/0056-ssh-preset-forwards.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; +import * as presetSharingMigration from "./migrations/0057-preset-sharing.js"; /** * Runs all pending Kysely migrations against the app database. @@ -167,6 +168,7 @@ export async function runMigrations(): Promise { "0054-ssh-presets": sshPresetsMigration, "0055-node-ssh-wide-forwards": sshWideForwardsMigration, "0056-ssh-preset-forwards": sshPresetForwardsMigration, + "0057-preset-sharing": presetSharingMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0057-preset-sharing.ts b/apps/server/api/src/db/migrations/0057-preset-sharing.ts new file mode 100644 index 000000000..7d0f2a0cd --- /dev/null +++ b/apps/server/api/src/db/migrations/0057-preset-sharing.ts @@ -0,0 +1,26 @@ +import { type Kysely, sql } from "kysely"; + +/** Separate foreign keys keep grants tied to the lifetime of each preset kind. */ +export async function up(db: Kysely): Promise { + for (const [table, parent] of [ + ["preset_shares", "presets"], + ["ssh_preset_shares", "ssh_presets"], + ] as const) { + await db.schema + .createTable(table) + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("preset_id", "text", (c) => c.notNull().references(`${parent}.id`).onDelete("cascade")) + .addColumn("grantee_user_id", "text", (c) => c.references("user.id").onDelete("cascade")) + .addColumn("permission", "text", (c) => c.notNull().check(sql`permission IN ('view', 'edit')`)) + .addColumn("created_by", "text", (c) => c.notNull().references("user.id").onDelete("cascade")) + .addColumn("created_at", "text", (c) => c.notNull()) + .execute(); + await db.schema.createIndex(`idx_${table}_preset`).on(table).column("preset_id").execute(); + await db.schema.createIndex(`idx_${table}_grantee`).on(table).column("grantee_user_id").execute(); + } +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("ssh_preset_shares").execute(); + await db.schema.dropTable("preset_shares").execute(); +} diff --git a/apps/server/api/src/db/repositories/__tests__/presets-by-harness.test.ts b/apps/server/api/src/db/repositories/__tests__/presets-by-harness.test.ts index 94bd370ff..5285d38a7 100644 --- a/apps/server/api/src/db/repositories/__tests__/presets-by-harness.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/presets-by-harness.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; +import { setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; import { db } from "@/db/index.js"; -import { runMigrations } from "@/db/migrate.js"; import { PluginStateRepository } from "@/db/repositories/plugin-state.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -37,7 +37,7 @@ describe("PresetsRepository.listByHarness / deleteByHarness", () => { const bob = `byh-bob-${crypto.randomUUID().slice(0, 8)}`; beforeAll(async () => { - await runMigrations(); + await setupAuthTables(); }); it("lists every user's presets for one harness, and nothing for another", async () => { @@ -94,7 +94,7 @@ describe("SubshellsRepository.countRunningByHarness", () => { const owner = `byh-sub-${crypto.randomUUID().slice(0, 8)}`; beforeAll(async () => { - await runMigrations(); + await setupAuthTables(); }); async function mkSubshell(harnessId: string, running: boolean): Promise { @@ -132,7 +132,7 @@ describe("PluginStateRepository (absent row means enabled)", () => { const id = `state-${crypto.randomUUID().slice(0, 8)}`; beforeAll(async () => { - await runMigrations(); + await setupAuthTables(); }); it("reads an absent row as enabled without writing anything", async () => { diff --git a/apps/server/api/src/db/repositories/preset-shares.repository.ts b/apps/server/api/src/db/repositories/preset-shares.repository.ts new file mode 100644 index 000000000..9e73c815a --- /dev/null +++ b/apps/server/api/src/db/repositories/preset-shares.repository.ts @@ -0,0 +1,39 @@ +import { BaseRepository } from "@/db/repositories/base.repository.js"; +import type { ShareEntry } from "@/db/repositories/subshell-shares.repository.js"; + +export type PresetKind = "agent" | "ssh"; + +/** The two preset kinds use the same grant contract with independent foreign keys. */ +export class PresetSharesRepository extends BaseRepository { + async list(kind: PresetKind, ids: string[]) { + if (!ids.length) return []; + return this.db + .selectFrom(kind === "agent" ? "presetShares" : "sshPresetShares") + .selectAll() + .where("presetId", "in", ids) + .orderBy("createdAt") + .execute(); + } + + async replace(kind: PresetKind, presetId: string, entries: ShareEntry[], createdBy: string) { + const table = kind === "agent" ? "presetShares" : "sshPresetShares"; + const unique = new Map(entries.map((entry) => [entry.granteeUserId, entry])); + await this.db.transaction().execute(async (tx) => { + await tx.deleteFrom(table).where("presetId", "=", presetId).execute(); + if (unique.size) + await tx + .insertInto(table) + .values( + [...unique.values()].map((entry) => ({ + ...entry, + id: crypto.randomUUID(), + presetId, + createdBy, + createdAt: new Date().toISOString(), + })), + ) + .execute(); + }); + return this.list(kind, [presetId]); + } +} diff --git a/apps/server/api/src/db/repositories/presets.repository.ts b/apps/server/api/src/db/repositories/presets.repository.ts index 9f8a0149b..cc03a6cf9 100644 --- a/apps/server/api/src/db/repositories/presets.repository.ts +++ b/apps/server/api/src/db/repositories/presets.repository.ts @@ -28,6 +28,27 @@ export class PresetsRepository extends BaseRepository { .executeTakeFirstOrThrow(); } + /** Cookie readers can see owned presets and matching named/Everyone grants. */ + async listAccessible(userId: string) { + return this.db + .selectFrom("presets") + .selectAll("presets") + .where((eb) => + eb.or([ + eb("userId", "=", userId), + eb.exists( + eb + .selectFrom("presetShares") + .select("id") + .whereRef("presetId", "=", "presets.id") + .where((share) => share.or([share("granteeUserId", "is", null), share("granteeUserId", "=", userId)])), + ), + ]), + ) + .orderBy("name") + .execute(); + } + async findById(id: string): Promise { return this.db.selectFrom("presets").selectAll().where("id", "=", id).executeTakeFirst(); } diff --git a/apps/server/api/src/db/repositories/ssh-presets.repository.ts b/apps/server/api/src/db/repositories/ssh-presets.repository.ts index b5a52e4ed..7648e7664 100644 --- a/apps/server/api/src/db/repositories/ssh-presets.repository.ts +++ b/apps/server/api/src/db/repositories/ssh-presets.repository.ts @@ -3,8 +3,8 @@ import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; /** * Saved SSH connection presets. This repository holds database calls only: it - * scopes LIST by owner, but `findById`/`update`/`delete` ask by id alone, and - * the handler compares `userId` after the read and renders a foreign row as + * offers owned and accessible lists, but `findById`/`update`/`delete` ask by id + * alone. Handlers enforce viewer access and render an inaccessible row as * the same 404 an absent one gets (the ownership axis, docs/security.md §3). * `update` takes the WHOLE row as the service composes it, partial only so * the caller can omit what it is not deciding about. @@ -13,6 +13,27 @@ export class SshPresetsRepository extends BaseRepository { async listByUser(userId: string) { return this.db.selectFrom("sshPresets").selectAll().where("userId", "=", userId).orderBy("name").execute(); } + /** Cookie readers can see owned presets and matching named/Everyone grants. */ + async listAccessible(userId: string) { + return this.db + .selectFrom("sshPresets") + .selectAll("sshPresets") + .where((eb) => + eb.or([ + eb("userId", "=", userId), + eb.exists( + eb + .selectFrom("sshPresetShares") + .select("id") + .whereRef("presetId", "=", "sshPresets.id") + .where((share) => share.or([share("granteeUserId", "is", null), share("granteeUserId", "=", userId)])), + ), + ]), + ) + .orderBy("name") + .execute(); + } + async findById(id: string) { return this.db.selectFrom("sshPresets").selectAll().where("id", "=", id).executeTakeFirst(); } diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 35891e747..a669d7fde 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -12,6 +12,7 @@ import type { NodeShareTable } from "@/db/types/node-shares.db-types.js"; import type { NodeTable } from "@/db/types/nodes.db-types.js"; import type { NotificationSubscriptionTable } from "@/db/types/notification-subscriptions.db-types.js"; import type { PluginStateTable } from "@/db/types/plugin-state.db-types.js"; +import type { PresetShareTable } from "@/db/types/preset-shares.db-types.js"; import type { PresetTable } from "@/db/types/presets.db-types.js"; import type { PromptStackItemTable, PromptStackTable } from "@/db/types/prompt-stacks.db-types.js"; import type { PromptTable } from "@/db/types/prompts.db-types.js"; @@ -32,6 +33,8 @@ import type { WorkspaceTable } from "@/db/types/workspaces.db-types.js"; * them separately.) */ export interface Database { + presetShares: PresetShareTable; + sshPresetShares: PresetShareTable; authAttempts: AuthAttemptsTable; auditEvents: AuditEventsTable; authProviders: AuthProviderTable; diff --git a/apps/server/api/src/db/types/preset-shares.db-types.ts b/apps/server/api/src/db/types/preset-shares.db-types.ts new file mode 100644 index 000000000..89cef420c --- /dev/null +++ b/apps/server/api/src/db/types/preset-shares.db-types.ts @@ -0,0 +1,8 @@ +export interface PresetShareTable { + id: string; + presetId: string; + granteeUserId: string | null; + permission: "view" | "edit"; + createdBy: string; + createdAt: string; +} diff --git a/apps/server/api/src/lib/preset-access.ts b/apps/server/api/src/lib/preset-access.ts new file mode 100644 index 000000000..cae9f5555 --- /dev/null +++ b/apps/server/api/src/lib/preset-access.ts @@ -0,0 +1,51 @@ +import type { Kysely } from "kysely"; +import { type PresetKind, PresetSharesRepository } from "@/db/repositories/preset-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { Database } from "@/db/types/index.js"; +import { resolveSubshellAccess } from "@/lib/subshell-access.js"; + +/** No admin override; machine actors retain owner-only preset access. */ +export async function presetAccess( + db: Kysely, + kind: PresetKind, + row: { id: string; userId: string }, + viewerId: string, + allowShares = true, +) { + if (row.userId === viewerId) return "owner" as const; + return resolveSubshellAccess( + viewerId, + row.userId, + allowShares ? await new PresetSharesRepository(db).list(kind, [row.id]) : [], + ); +} + +/** Batch grants once for a list, preserving the same effective-access resolver. */ +export async function withPresetAccess( + db: Kysely, + kind: PresetKind, + rows: T[], + viewerId: string, + allowShares = true, +) { + const shares = allowShares + ? await new PresetSharesRepository(db).list( + kind, + rows.map((row) => row.id), + ) + : []; + const names = await new UsersRepository(db).displayNamesByIds([...new Set(rows.map((row) => row.userId))]); + return rows.flatMap((row) => { + const access = resolveSubshellAccess( + viewerId, + row.userId, + shares.filter((share) => share.presetId === row.id), + ); + return access === "none" ? [] : [{ ...row, access, ownerName: names.get(row.userId) ?? row.userId }]; + }); +} + +/** Mutations return the same owner label as lists, so optimistic cache updates keep attribution. */ +export async function presetOwnerName(db: Kysely, userId: string): Promise { + return (await new UsersRepository(db).displayNamesByIds([userId])).get(userId) ?? userId; +} diff --git a/apps/server/api/src/lib/preset-name-error.ts b/apps/server/api/src/lib/preset-name-error.ts new file mode 100644 index 000000000..404c726d5 --- /dev/null +++ b/apps/server/api/src/lib/preset-name-error.ts @@ -0,0 +1,11 @@ +import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; + +/** Keep duplicate names distinguishable from other 409s without parsing display copy. */ +export function duplicatePresetName(message: string): never { + return throwApiError({ + code: BackendErrorCodes.EXISTS_ERROR, + message, + metadataSafe: { reason: "duplicate_preset_name", field: "name" }, + doNotLog: true, + }); +} diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index a518b7020..6bb321a3e 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -1788,7 +1788,7 @@ describe("applyRemoteExit — shared death transition (idempotent against sweep }, sendNode: f.sendNode, }); - const livePreset = await seedPreset(presetsRepo); + const livePreset = await seedPreset(presetsRepo, { userId: "u-recon" }); const id = await seedNodeRow(nodeId, { alive: 0, presetId: livePreset }); // parked row → restart revives it const restartP = gated.restartSubshell("u-recon", id); for (let i = 0; !reachedIssue && i < 2000; i++) await new Promise((r) => setTimeout(r, 1)); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index b96f2e69a..a7887da53 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0056-ssh-preset-forwards"; +export const LATEST_BACKUP_MIGRATION = "0057-preset-sharing"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/preset-sharing.service.ts b/apps/server/api/src/services/preset-sharing.service.ts new file mode 100644 index 000000000..cf54a85c8 --- /dev/null +++ b/apps/server/api/src/services/preset-sharing.service.ts @@ -0,0 +1,40 @@ +import type { Kysely } from "kysely"; +import { HttpError } from "@/api/auth-guard.js"; +import { PresetSharesRepository } from "@/db/repositories/preset-shares.repository.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; +import type { ShareEntry } from "@/db/repositories/subshell-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import type { Database } from "@/db/types/index.js"; +import { presetAccess } from "@/lib/preset-access.js"; + +/** The owner alone controls grants, even when another viewer can edit the preset. */ +export async function presetShares(db: Kysely, viewerId: string, id: string, entries?: ShareEntry[]) { + const agent = await new PresetsRepository(db).findById(id); + const row = agent ?? (await new SshPresetsRepository(db).findById(id)); + const kind = agent ? "agent" : "ssh"; + if (!row) throw new HttpError(404, "Preset not found"); + const access = await presetAccess(db, kind, row, viewerId); + if (access === "none") throw new HttpError(404, "Preset not found"); + if (access !== "owner") throw new HttpError(403, "Only the owner can manage preset sharing."); + const users = new UsersRepository(db); + const shares = new PresetSharesRepository(db); + if (entries) { + const named = entries.flatMap((entry) => (entry.granteeUserId === null ? [] : [entry.granteeUserId])); + const names = await users.displayNamesByIds(named); + if (named.some((id) => !names.has(id))) throw new HttpError(400, "Cannot share with an unknown user"); + await shares.replace(kind, id, entries, viewerId); + } + const rows = await shares.list(kind, [id]); + const names = await users.displayNamesByIds( + rows.flatMap((row) => (row.granteeUserId === null ? [] : [row.granteeUserId])), + ); + return { + shares: rows.map((row) => ({ + id: row.id, + granteeUserId: row.granteeUserId, + permission: row.permission, + granteeName: row.granteeUserId === null ? "Everyone" : (names.get(row.granteeUserId) ?? row.granteeUserId), + })), + }; +} diff --git a/apps/server/api/src/services/ssh-presets.service.ts b/apps/server/api/src/services/ssh-presets.service.ts index babee6248..f3507f584 100644 --- a/apps/server/api/src/services/ssh-presets.service.ts +++ b/apps/server/api/src/services/ssh-presets.service.ts @@ -7,6 +7,7 @@ import { db } from "@/db/index.js"; import { SshPresetsRepository } from "@/db/repositories/ssh-presets.repository.js"; import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; import { nodeCanLaunchOn } from "@/lib/node-access.js"; +import { duplicatePresetName } from "@/lib/preset-name-error.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sshCanonicalDestination } from "@/services/ssh-destination.js"; import { sshResolveDestination } from "@/services/ssh-launch.service.js"; @@ -106,7 +107,7 @@ export async function saveSshPreset( const now = new Date().toISOString(); const row = { id: existing?.id ?? crypto.randomUUID(), - userId, + userId: existing?.userId ?? userId, name, description: body.description !== undefined ? body.description : (existing?.description ?? null), nodeId: body.nodeId, @@ -128,7 +129,7 @@ export async function saveSshPreset( return sshPresetView(existing ? await repo.update(existing.id, row) : await repo.create(row)); } catch (err) { if (err instanceof Error && err.message.includes("UNIQUE")) - throw new SshPresetError("duplicate", "You already have an SSH preset with that name", 409); + duplicatePresetName("You already have an SSH preset with that name"); throw err; } } diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index d6daff0d8..b9524d280 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -27,6 +27,7 @@ import type { SubshellsRepository } from "@/db/repositories/subshells.repository import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import type { SubshellTable, SubshellUpdate } from "@/db/types/subshells.db-types.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { presetAccess } from "@/lib/preset-access.js"; import type { Access } from "@/lib/subshell-access.js"; import { type AuditEventInput, audit } from "@/services/audit.js"; import { publishLive } from "@/services/live-bus.js"; @@ -348,6 +349,7 @@ export class SubshellManagerService { userId, harnessId, presetId, + allowSharedPreset = false, workingDir, name, prompt, @@ -367,6 +369,7 @@ export class SubshellManagerService { harnessId: string; /** Preset row to launch with; absent/null = the launch composes from EMPTY_PRESET. */ presetId?: string | null; + allowSharedPreset?: boolean; workingDir: string; name?: string; /** Optional task text typed into the pane after the harness settles. */ @@ -435,7 +438,10 @@ export class SubshellManagerService { throw new Error("createSubshell: subshellId is outside the id grammar"); } const presetRow = presetId ? await this.#presets.findById(presetId) : undefined; - if (presetId && (!presetRow || presetRow.userId !== userId)) { + if ( + presetId && + (!presetRow || (await presetAccess(this.#presets.db, "agent", presetRow, userId, allowSharedPreset)) === "none") + ) { throw new Error("Preset not found"); } if (presetRow && presetRow.harnessId !== harnessId) { @@ -903,6 +909,15 @@ export class SubshellManagerService { return existing; // same owner, already restarting — join it } const run = (async (): Promise<{ id: string; tmuxSocket: string } | null> => { + // Recheck here as well as in the service so a direct manager call or a + // revoked grant cannot stop a live pane before discovering the refusal. + const nextPresetId = swapPresetTo === undefined ? source.presetId : swapPresetTo; + if (nextPresetId) { + const preset = await this.#presets.findById(nextPresetId); + if (preset && (await presetAccess(this.#presets.db, "agent", preset, source.userId)) === "none") { + throw new Error("Preset access was removed. Choose another preset before restarting this subshell."); + } + } if (source.alive === 1 && source.tmuxSocket) { // killSubshell swallows "already gone"; the tree dies with its baked // key, which #reviveRow then rotates off the same row anyway. @@ -1310,7 +1325,12 @@ export class SubshellManagerService { // (spec 2026-09-13 §4), and it revives as exactly what it was created // as: EMPTY_PRESET. Only a NON-NULL reference whose row vanished throws. const presetRow = row.presetId ? await this.#presets.findById(row.presetId) : undefined; - if (row.presetId && !presetRow) throw new Error("preset missing"); + if ( + row.presetId && + (!presetRow || (await presetAccess(this.#presets.db, "agent", presetRow, row.userId)) === "none") + ) { + throw new Error("Preset access was removed. Choose another preset before restarting this subshell."); + } const harness = getHarness(row.harnessId); if (!harness) throw new Error("harness missing"); // The row's node owns this revive end to end (spec §6.3). An agent that diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 76a12fd93..6d1b6e4dc 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -18,6 +18,7 @@ import type { PresetTable } from "@/db/types/presets.db-types.js"; import type { SubshellSharePermission } from "@/db/types/subshell-shares.db-types.js"; import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } from "@/lib/node-access.js"; +import { presetAccess } from "@/lib/preset-access.js"; import { paneInputAllowed } from "@/lib/ssh-pane-access.js"; import { type Access, accessAtLeast, loadSubshellAccess, resolveSubshellAccess } from "@/lib/subshell-access.js"; import { BaseService, type CommonServiceParams } from "@/services/base.service.js"; @@ -513,7 +514,7 @@ export class SubshellsService extends BaseService { let presetRow: PresetTable | undefined; if (presetId) { presetRow = await this.repos.presets.findById(presetId); - if (!presetRow || presetRow.userId !== userId) { + if (!presetRow || (await presetAccess(this.db, "agent", presetRow, userId, !machineActor)) === "none") { throw new SubshellCreateError("not_found", "Preset not found", 404); } if (presetRow.harnessId !== harnessId) { @@ -644,6 +645,7 @@ export class SubshellsService extends BaseService { userId, harnessId, presetId: presetId ?? null, + allowSharedPreset: !machineActor, workingDir: launchWorkingDir, name, prompt: resolvedPrompt, @@ -1387,23 +1389,38 @@ export class SubshellsService extends BaseService { // The swap is validated HERE — after the gate and the maintenance 409, // before the manager touches anything — so a restart refused on this // side of the manager never changes the preset (spec 2026-09-23 §2). The - // preset must be the CALLER's (the - // same per-user rule create enforces): a pane-token actor resolves through - // the guard as its row's owner, so its swap lands on the owner's presets; - // the system service user owns nothing and holds no grants, so the edit - // gate above refuses it before this validation. And one of this row's - // harness: a harness switch on a live row would silently resume another - // agent's transcript in a different CLI. + // viewer and pane owner both need access to the chosen preset; otherwise + // a successful swap would leave the owner unable to restart it later. + // Machine actors retain owner-only access. The harness must still match + // the pane so a restart cannot resume a transcript in another CLI. if (swapPresetTo !== undefined && swapPresetTo !== null) { const preset = await this.repos.presets.findById(swapPresetTo); - if (!preset || preset.userId !== viewerId || preset.harnessId !== row.harnessId) { + if ( + !preset || + (await presetAccess(this.db, "agent", preset, viewerId, actor === "cookie")) === "none" || + (await presetAccess(this.db, "agent", preset, row.userId)) === "none" || + preset.harnessId !== row.harnessId + ) { throwApiError({ code: BackendErrorCodes.INVALID_PRESET, - message: "The preset must exist, belong to you, and match the subshell's harness", + message: "The preset must be accessible to you and the subshell owner and match the subshell's harness", doNotLog: true, }); } } + // Revocation affects future starts, not the terminal that is already running. + // Check before the manager stops a live pane or changes its preset. + const restartPresetId = swapPresetTo === undefined ? row.presetId : swapPresetTo; + if (restartPresetId) { + const preset = await this.repos.presets.findById(restartPresetId); + if (!preset || (await presetAccess(this.db, "agent", preset, row.userId, actor === "cookie")) === "none") { + throw new SubshellCreateError( + "not_found", + "Preset access was removed. Choose another preset before restarting this subshell.", + 404, + ); + } + } // The offline pre-gate for a swap-carrying restart (final review 2026-09-24). // The manager's kill only protects an ALIVE row: a dead row skips the kill, // the swap write lands, and `#reviveRow`'s first node RPC is what throws diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index db07bd885..c915c736d 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -458,3 +458,23 @@ in any future recovery UI. Enrollment without a confirmed connection is terminal status `enrolled`, with the node id retained. Link to that node and its service recovery; do not offer another install or describe it as connected. + +## Preset sharing + +Agent and SSH presets use the same owner-controlled View/Edit grants as +subshells. The API returns viewer-relative access and owner names; library +actions, read-only details, and launch pickers consume that metadata. Share +and Delete stay owner-only. The shared sharing dialog stages grants until +Save and must not treat a failed grant read as an empty list. See +[the user guide](../../docs/content/docs/presets/use.mdx#share-a-preset) and +[the security model](../../../docs/security.md) for disclosure and launch +boundaries. + + +Preset file version 1 is validated by `src/lib/preset-file.ts`. Export projects +settings explicitly, excluding ownership and grants. Import parses before any +write, then calls the ordinary create API, so plugin, node, and SSH checks +remain authoritative. SSH imports carry the exported destination as the +expected destination while resolving their saved alias; never silently follow +an alias to another host. The file limit is 1 MiB. Keep validation, exports, +and the public guide aligned when adding a field or version. diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index ba0dc176a..cbcfaf062 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -1162,14 +1162,14 @@ describe("NetworkPluginCard: the state matrix", () => { expect(screen.getByRole("button", { name: "Publish" })).toBeTruthy(); }); - it("labels each address above its value, in the QUIET data-label grammar", async () => { - // Facts use smaller muted labels and full-size values across cards and dialogs. + it("labels each address above its value with the shared fact styling", async () => { + // Facts use smaller bold, muted labels and regular full-size values across cards and dialogs. await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } })); const items = ADDRESSES.map((address) => screen.getByText(address.label).closest("div") as HTMLElement); const first = items[0] as HTMLElement; expect(first.children[0]?.textContent).toBe("MagicDNS name"); expect(first.children[0]?.className).toContain("text-muted-foreground"); - expect(first.children[0]?.className).not.toContain("font-strong"); + expect(first.children[0]?.className).toContain("font-strong"); expect(first.children[0]?.className).not.toContain("text-label"); expect(first.children[0]?.className).toContain("text-detail"); expect(first.children[1]?.className).toContain("text-body"); diff --git a/apps/server/web/src/components/connect/ssh-preset-controls.tsx b/apps/server/web/src/components/connect/ssh-preset-controls.tsx index 121595426..4f654a5fb 100644 --- a/apps/server/web/src/components/connect/ssh-preset-controls.tsx +++ b/apps/server/web/src/components/connect/ssh-preset-controls.tsx @@ -6,6 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { SearchableSelect } from "@/components/ui/combobox"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { useSshPresets } from "@/hooks/use-ssh-presets"; +import { presetChoiceLabel } from "@/lib/preset-access"; import type { SshPreset } from "@/types/preset"; /** Picking fills the form; it never starts SSH or retains a previously chosen local key. */ @@ -53,7 +54,7 @@ export function SshPresetPicker({ placeholder="Choose a preset" options={query.data.map((p) => ({ value: p.id, - label: p.name, + label: presetChoiceLabel(p), searchText: p.destination, disabled, reason: disabled ? "Wait for the current connection attempt to finish." : undefined, diff --git a/apps/server/web/src/components/fact-list.tsx b/apps/server/web/src/components/fact-list.tsx index 30d05dc6b..c2b554d4e 100644 --- a/apps/server/web/src/components/fact-list.tsx +++ b/apps/server/web/src/components/fact-list.tsx @@ -1,9 +1,9 @@ import { CopyableValue, Fact } from "@internal/node-admin"; import type { ReactNode } from "react"; -/** One labelled fact: a quiet label over a readable value, optionally copied. */ +/** One labelled fact: a small, bold label over a readable value, optionally copied. */ export interface FactRow { - /** The quiet label above the value; also names the copy button. */ + /** The small, bold label above the value; also names the copy button. */ label: string; /** The value. Ignored when `copyText` is set, since the copy row shows that text itself. */ value: ReactNode; diff --git a/apps/server/web/src/components/presets/__tests__/import-preset-dialog.test.tsx b/apps/server/web/src/components/presets/__tests__/import-preset-dialog.test.tsx new file mode 100644 index 000000000..6c3c8d2a4 --- /dev/null +++ b/apps/server/web/src/components/presets/__tests__/import-preset-dialog.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ImportPresetDialog } from "../import-preset-dialog"; + +const fileData = { + format: "subshell-preset", + version: 1, + preset: { + kind: "agent", + name: "Imported", + description: null, + harnessId: "terminal", + env: { VALUE: "kept" }, + flags: [], + settings: {}, + configIsolation: false, + restartOnExit: false, + crossCommEnabled: false, + nodeId: null, + workingDir: null, + promptBlocks: null, + }, +}; +const original = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = original; +}); +function mount(refuse = false) { + const posts: unknown[] = []; + const completed: string[] = []; + const closed: boolean[] = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + if (String(input).includes("/api/nodes")) return Response.json({ nodes: [] }); + if (init?.method === "POST") { + posts.push(JSON.parse(String(init.body))); + return refuse + ? Response.json( + { + message: 'You already have a terminal preset named "Imported"', + code: "EXISTS_ERROR", + metadata: { reason: "duplicate_preset_name", field: "name" }, + }, + { status: 409 }, + ) + : Response.json({ id: "new" }); + } + return Response.json([]); + }) as typeof fetch; + const cache = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render( + + closed.push(true)} onImported={(kind) => completed.push(kind)} /> + , + ); + return { posts, completed, closed }; +} +function choose(text: string) { + fireEvent.change(screen.getByLabelText("Preset file"), { + target: { files: [new File([text], "test.preset", { type: "application/json" })] }, + }); +} +describe("Import preset dialog", () => { + it("uses a native file input and rejects bad files without a write", async () => { + const { posts } = mount(); + expect(screen.getByLabelText("Preset file").getAttribute("type")).toBe("file"); + expect(screen.getByRole("button", { name: "Import preset" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + choose("{"); + await screen.findByText(/isn't valid JSON/); + expect(posts).toHaveLength(0); + expect(screen.getByRole("button", { name: "Import preset" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + choose(JSON.stringify({ ...fileData, version: 99 })); + await screen.findByText(/unsupported version/); + expect(posts).toHaveLength(0); + }); + it("validates then explicitly imports a private copy with the reviewed name", async () => { + const { posts, completed } = mount(); + choose(JSON.stringify(fileData)); + const name = await screen.findByLabelText("Name"); + fireEvent.change(name, { target: { value: "Renamed import" } }); + expect(posts).toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "Import preset" })); + await waitFor(() => expect(completed).toEqual(["agent"])); + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ name: "Renamed import", harnessId: "terminal", env: { VALUE: "kept" } }); + expect(Object.keys(posts[0] as object)).not.toContain("shares"); + expect(Object.keys(posts[0] as object)).not.toContain("userId"); + }); + it("keeps a refused import open so its name or node can be corrected", async () => { + const { completed } = mount(true); + choose(JSON.stringify(fileData)); + await screen.findByLabelText("Name"); + fireEvent.click(screen.getByRole("button", { name: "Import preset" })); + await screen.findByText("You already have a preset with this name. Choose a different name to import a copy."); + const name = screen.getByLabelText("Name"); + await waitFor(() => expect(document.activeElement === name).toBe(true)); + expect(name.getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByText(/API 409/) === null).toBe(true); + fireEvent.change(name, { target: { value: "Another name" } }); + expect(name.getAttribute("aria-invalid")).toBe("false"); + expect(screen.queryByRole("alert") === null).toBe(true); + expect(completed).toHaveLength(0); + expect(screen.getByLabelText("Name")).toBeDefined(); + }); + it("discards a previously valid draft when the next file fails validation", async () => { + const { posts } = mount(); + choose(JSON.stringify(fileData)); + await screen.findByLabelText("Name"); + choose("{}"); + await screen.findByText(/This preset file is invalid/); + expect(screen.queryByLabelText("Name") === null).toBe(true); + expect(screen.getByRole("button", { name: "Import preset" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(posts).toHaveLength(0); + }); +}); + +it("keeps an import draft on Escape and closes only on explicit cancellation", async () => { + const { closed } = mount(); + choose(JSON.stringify(fileData)); + const name = await screen.findByLabelText("Name"); + fireEvent.change(name, { target: { value: "Keep my changes" } }); + fireEvent.keyDown(name, { key: "Escape", code: "Escape" }); + expect(closed).toHaveLength(0); + expect((name as HTMLInputElement).value).toBe("Keep my changes"); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(closed).toHaveLength(1); +}); diff --git a/apps/server/web/src/components/presets/__tests__/preset-sharing-dialog.test.tsx b/apps/server/web/src/components/presets/__tests__/preset-sharing-dialog.test.tsx new file mode 100644 index 000000000..5d3b70ef5 --- /dev/null +++ b/apps/server/web/src/components/presets/__tests__/preset-sharing-dialog.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, expect, it } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SharingDialogCore } from "@/components/sharing-dialog-core"; +import { PresetSharingDialog } from "../preset-sharing-dialog"; + +const original = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = original; +}); +it("refuses edits after a failed grant read and recovers the existing grants on retry", async () => { + let failed = true; + let writes = 0; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + if (init?.method === "PUT") { + writes++; + return Response.json({ shares: [] }); + } + if (String(input).endsWith("/shares")) { + return failed + ? Response.json({ message: "Unavailable" }, { status: 503 }) + : Response.json({ + shares: [{ id: "s", granteeUserId: null, granteeName: "Everyone", permission: "view" }], + }); + } + return Response.json({ users: [] }); + }) as typeof fetch; + const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} /> + , + ); + await screen.findByText("Couldn't load sharing settings. Try again before making changes."); + expect(screen.queryByRole("button", { name: "Add" }) === null).toBe(true); + expect(screen.getByRole("button", { name: "Save" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(screen.queryByText("Not shared with anyone yet.") === null).toBe(true); + failed = false; + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("button", { name: "Remove access for Everyone" }); + await waitFor(() => + expect(screen.queryByText("Couldn't load sharing settings. Try again before making changes.") === null).toBe(true), + ); + expect(screen.getByRole("button", { name: "Save" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(writes).toBe(0); +}); + +it("keeps sharing open and disables edits until a pending save finishes", async () => { + let finish!: () => void; + const closed: boolean[] = []; + render( + closed.push(open)} + title="Share preset" + description="Share settings" + isLoading={false} + serverGrants={[{ granteeUserId: null, permission: "view" }]} + roster={[]} + saving={false} + onSave={() => + new Promise((resolve) => { + finish = resolve; + }) + } + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Remove access for Everyone" })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(screen.getByRole("button", { name: "Cancel" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(screen.getByRole("button", { name: "Add" }).matches(":disabled, [aria-disabled=true]")).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape", code: "Escape" }); + expect(closed).toHaveLength(0); + await act(async () => finish()); + await waitFor(() => expect(closed).toEqual([false])); +}); diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx index ecb63be29..22c3d89d4 100644 --- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx +++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx @@ -238,7 +238,11 @@ it("places a duplicate-name refusal beside the name and clears it when edited", globalThis.fetch = (async (input: unknown, init?: RequestInit) => { if (init?.method === "PUT") return Response.json( - { code: "duplicate", message: "You already have an SSH preset with that name" }, + { + code: "EXISTS_ERROR", + metadata: { reason: "duplicate_preset_name", field: "name" }, + message: "You already have an SSH preset with that name", + }, { status: 409 }, ); return previousFetch(input as RequestInfo, init); diff --git a/apps/server/web/src/components/presets/agent-preset-library.tsx b/apps/server/web/src/components/presets/agent-preset-library.tsx index d3f2a9599..de92c96ab 100644 --- a/apps/server/web/src/components/presets/agent-preset-library.tsx +++ b/apps/server/web/src/components/presets/agent-preset-library.tsx @@ -1,15 +1,18 @@ import { apiFetch, Button, Card, CardContent, CardHeader, confirmAction, errMessage } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; -import { Copy, Pencil, SlidersHorizontal, Trash2 } from "lucide-react"; +import { Copy, Download, Pencil, Share2, SlidersHorizontal, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { EmptyState } from "@/components/empty-state"; import { ErrorBanner } from "@/components/error-banner"; import { LoadingCard } from "@/components/loading-card"; import { PluginIcon } from "@/components/plugin-icon"; import { CreatePresetDialog } from "@/components/presets/create-preset-dialog"; +import { PresetSharingDialog } from "@/components/presets/preset-sharing-dialog"; import { PresetTableRow } from "@/components/presets/preset-table-row"; import { useInstancePlugins } from "@/hooks/use-instance-plugins"; import { useInvalidatePresets, usePresets } from "@/hooks/use-presets"; +import { canEditPreset, ownsPreset } from "@/lib/preset-access"; +import { downloadPreset } from "@/lib/preset-file"; import { presetFormFromRow, suggestCloneName } from "@/lib/preset-form"; import type { PresetRow } from "@/types/preset"; @@ -46,6 +49,7 @@ export function AgentPresetLibrary({ onCreatedClose: () => void; }) { const navigate = useNavigate(); + const [sharing, setSharing] = useState(null); const invalidate = useInvalidatePresets(); const { data: presets, isLoading, isError, refetch } = usePresets(); const { data: pluginData } = useInstancePlugins(); @@ -85,6 +89,7 @@ export function AgentPresetLibrary({ return ( <> + {sharing && setSharing(null)} />} {listError &&

{listError}

} {/* A dialog rather than a card that pushes the list down the page @@ -170,6 +175,9 @@ export function AgentPresetLibrary({ Cross-agent use + + Owner + Actions @@ -183,21 +191,41 @@ export function AgentPresetLibrary({ binary={plugin?.binary ?? harnessId} items={[ { - label: "Edit", + label: canEditPreset(p) ? "Edit" : "View preset", icon: Pencil, onSelect: () => void navigate({ to: "/presets/$id", params: { id: p.id } }), }, + { + label: "Export preset", + icon: Download, + onSelect: () => { + try { + downloadPreset(p); + } catch (error) { + setListError(errMessage(error, "Couldn't export this preset.")); + } + }, + }, { label: "Clone preset", icon: Copy, onSelect: () => setCloneSource(p), }, - { - label: "Delete preset", - icon: Trash2, - destructive: true, - onSelect: () => void deletePreset(p.id), - }, + ...(ownsPreset(p) + ? [ + { + label: "Share preset", + icon: Share2, + onSelect: () => setSharing(p.id), + }, + { + label: "Delete preset", + icon: Trash2, + destructive: true, + onSelect: () => void deletePreset(p.id), + }, + ] + : []), ]} /> ))} diff --git a/apps/server/web/src/components/presets/import-preset-dialog.tsx b/apps/server/web/src/components/presets/import-preset-dialog.tsx new file mode 100644 index 000000000..085e08cc4 --- /dev/null +++ b/apps/server/web/src/components/presets/import-preset-dialog.tsx @@ -0,0 +1,284 @@ +import { ApiError, apiPost, Button, errMessage, Input } from "@internal/node-admin"; +import { useStore } from "@tanstack/react-form"; +import { useEffect, useRef, useState } from "react"; +import { FactList } from "@/components/fact-list"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + formDialogOpenChange, +} from "@/components/ui/dialog"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { ErrorNotice, WarningNotice } from "@/components/ui/notice"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useNodes } from "@/hooks/use-nodes"; +import { useInvalidatePresets } from "@/hooks/use-presets"; +import { type FieldProblems, makeForm, useSubmitDisabled, useSubmitDisabledReason } from "@/lib/form"; +import { isDuplicatePresetName } from "@/lib/preset-errors"; +import { + type ImportedPreset, + PRESET_FILE_MAX_BYTES, + PresetFileSchema, + parsePresetFile, + presetImportPayload, +} from "@/lib/preset-file"; + +function importProblems(preset: ImportedPreset | null): FieldProblems { + if (!preset) return { preset: "Choose a valid .preset file first." }; + const result = PresetFileSchema.safeParse({ format: "subshell-preset", version: 1, preset }); + if (!result.success) return { preset: result.error.issues[0]?.message ?? "Check the preset settings." }; + if (preset.kind === "agent" && preset.crossCommEnabled && !preset.nodeId) + return { preset: "Choose a launch node for cross-subshell communication." }; + if (preset.kind === "ssh" && !preset.nodeId) return { preset: "Choose a connecting node." }; + if (preset.kind === "ssh" && preset.authentication === "remote" && !preset.keyHome) + return { preset: "Choose a node to supply the SSH keys." }; + return {}; +} + +/** Parsing never writes. The explicit import uses the ordinary, permission-checked create API. */ +export function ImportPresetDialog({ + onClose, + onImported, +}: { + onClose: () => void; + onImported: (kind: "agent" | "ssh") => void; +}) { + const invalidate = useInvalidatePresets(); + const nodes = useNodes(); + const [reading, setReading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [nameError, setNameError] = useState(null); + const nameInput = useRef(null); + useEffect(() => { + if (nameError && !saving) { + nameInput.current?.focus(); + nameInput.current?.select(); + } + }, [nameError, saving]); + const generation = useRef(0); + const picker = useRef(null); + const [fileName, setFileName] = useState(null); + useEffect( + () => () => { + generation.current++; + }, + [], + ); + const form = makeForm({ + defaultValues: { preset: null as ImportedPreset | null }, + validator: ({ preset }) => importProblems(preset), + onSubmit: async ({ preset }) => { + if (reading || saving || !preset || Object.keys(importProblems(preset)).length) return; + setSaving(true); + setError(null); + try { + await apiPost("/api/presets", presetImportPayload(preset)); + await invalidate(); + onImported(preset.kind); + } catch (err) { + if (isDuplicatePresetName(err)) { + setNameError("You already have a preset with this name. Choose a different name to import a copy."); + return; + } + setError( + err instanceof ApiError && err.status === 422 + ? "The SSH destination couldn't be verified. Review its connection choices in a new SSH preset before saving it." + : errMessage(err, "Couldn't import this preset. Check its settings and try again."), + ); + } finally { + setSaving(false); + } + }, + }); + const preset = useStore(form.store, (state) => state.values.preset); + const busy = reading || saving; + const disabled = useSubmitDisabled(form, busy); + const disabledReason = useSubmitDisabledReason(form, busy); + async function choose(file: File | undefined) { + if (!file) return; + setFileName(file.name); + setNameError(null); + const current = ++generation.current; + form.setFieldValue("preset", null); + setError(null); + if (file.size > PRESET_FILE_MAX_BYTES) { + setReading(false); + setError("Choose a preset file smaller than 1 MiB."); + return; + } + setReading(true); + try { + const parsed = parsePresetFile(await file.text()); + if (current === generation.current) form.setFieldValue("preset", parsed.preset); + } catch (err) { + if (current === generation.current) setError(errMessage(err, "Couldn't read this preset file.")); + } finally { + if (current === generation.current) setReading(false); + } + } + function nodeField(label: string, key: "nodeId" | "keyHome", nullable: boolean) { + if (!preset) return null; + const value = key === "nodeId" ? preset.nodeId : preset.kind === "ssh" ? preset.keyHome : null; + const choices = [ + ...(nullable ? [{ value: "none", label: "Choose when launching" }] : []), + ...(nodes.data?.nodes ?? []).map((node) => ({ value: node.id, label: node.name })), + ]; + if (value && !choices.some((choice) => choice.value === value)) + choices.push({ value, label: "Node unavailable on this instance" }); + return ( + + {label} + + + ); + } + return ( + { + if (!open && !saving) onClose(); + })} + > + + + Import preset + + Choose an exported .preset file. Import creates a private copy in your account without starting a subshell. + + +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + className="flex flex-col gap-4" + > + + + void choose(event.target.files?.[0])} + /> + + {fileName &&

{fileName}

} +
+ {reading && ( +

+ Validating file… +

+ )} + {preset && ( + <> + + + Name + { + setNameError(null); + form.setFieldValue("preset", { ...preset, name: event.target.value }); + }} + /> + {nameError && ( + + )} + + {nodeField(preset.kind === "ssh" ? "Connect from" : "Launch node", "nodeId", preset.kind === "agent")} + {preset.kind === "ssh" && + preset.authentication === "remote" && + nodeField("Use keys from", "keyHome", false)} +

+ Choose nodes on this instance. Access and availability are checked before the preset is saved. +

+
+ Review settings +

Preset file format: version 1

+
+                    {JSON.stringify(preset, null, 2)}
+                  
+
+ + {preset.kind === "agent" + ? "Only import settings you trust. This file can contain environment secrets, command flags, and prompts that affect future launches." + : "Importing saves connection choices, not SSH keys or destination approvals. Those checks still apply when connecting."} + + + )} + {nodes.isError && preset && ( + + Couldn't load nodes.{" "} + + + )} + {error && {error}} +
+ + + + +
+
+
+ ); +} diff --git a/apps/server/web/src/components/presets/preset-details.tsx b/apps/server/web/src/components/presets/preset-details.tsx new file mode 100644 index 000000000..c4accf9fd --- /dev/null +++ b/apps/server/web/src/components/presets/preset-details.tsx @@ -0,0 +1,69 @@ +import { FactList } from "@/components/fact-list"; +import { useNodes } from "@/hooks/use-nodes"; +import { SSH_AUTHENTICATION_LABELS } from "@/lib/ssh-preset"; +import type { PresetRow, SshPreset } from "@/types/preset"; + +/** Shared viewers inspect the complete stored choices without editable controls. */ +export function PresetDetails({ preset }: { preset: PresetRow | SshPreset }) { + const nodes = useNodes(); + const nodeName = (id: string | null) => + nodes.data?.nodes.find((node) => node.id === id)?.name ?? (nodes.isPending ? "Loading…" : "Node unavailable"); + const common = [ + { label: "Name", value: preset.name }, + { label: "Description", value: preset.description || "None" }, + { label: "Owner", value: preset.ownerName ?? preset.userId ?? "You" }, + ]; + return ( + ( +
+ {forward.kind === "dynamic" ? "SOCKS proxy" : forward.kind === "local" ? "Local" : "Remote"}:{" "} + {forward.bindHost ?? "127.0.0.1"}:{forward.listenPort} + {forward.kind !== "dynamic" && ` → ${forward.targetHost}:${forward.targetPort}`} +
+ )) + : "None", + mono: true, + }, + ] + : [ + ...common, + { label: "Agent", value: preset.harnessId }, + { label: "Environment variables", value: preset.envJson ?? "None", mono: true }, + { label: "Flags", value: preset.flagsJson ?? "None", mono: true }, + { label: "Settings", value: preset.settingsJson ?? "None", mono: true }, + { label: "Isolate configuration", value: preset.configIsolation ? "Yes" : "No" }, + { label: "Auto-restart", value: preset.restartOnExit ? "Yes" : "No" }, + { label: "Cross-subshell communication", value: preset.crossCommEnabled ? "Yes" : "No" }, + { label: "Node", value: preset.nodeId ? nodeName(preset.nodeId) : "Choose when launching" }, + { label: "Working directory", value: preset.workingDir ?? "Choose when launching", mono: true }, + { label: "Prompt blocks", value: preset.promptBlocks ?? "None", mono: true }, + ] + } + /> + ); +} diff --git a/apps/server/web/src/components/presets/preset-sharing-dialog.tsx b/apps/server/web/src/components/presets/preset-sharing-dialog.tsx new file mode 100644 index 000000000..2afd98253 --- /dev/null +++ b/apps/server/web/src/components/presets/preset-sharing-dialog.tsx @@ -0,0 +1,50 @@ +import { Button } from "@internal/node-admin"; +import { ErrorBanner } from "@/components/error-banner"; +import { SharingDialogCore } from "@/components/sharing-dialog-core"; +import { usePresetShares, useSetPresetShares } from "@/hooks/use-preset-shares"; +import { useSharableUsers } from "@/hooks/use-subshell-shares"; + +export function PresetSharingDialog({ id, kind, onClose }: { id: string; kind: "agent" | "ssh"; onClose: () => void }) { + const shares = usePresetShares(id, true); + const roster = useSharableUsers(true); + const save = useSetPresetShares(id); + const failed = shares.isError || roster.isError; + return ( + { + if (!open) onClose(); + }} + title="Share preset" + description="Presets are private by default. View access lets people inspect, use, and clone this preset. Edit access also lets them change it. Only you can share or delete it." + warning={ + kind === "agent" + ? "Sharing exposes all saved settings, including environment variables and any secrets in them. Edits affect future launches and restarts for everyone using this preset." + : "Sharing exposes the destination and saved connection choices. It doesn't grant access to nodes, SSH keys, or destination approvals." + } + loadError={ + failed ? ( + { + void shares.refetch(); + void roster.refetch(); + }} + > + Retry + + } + /> + ) : undefined + } + isLoading={shares.isPending || roster.isPending} + serverGrants={shares.data?.shares ?? []} + roster={roster.data ?? []} + saving={save.isPending} + onSave={(grants) => save.mutateAsync(grants)} + /> + ); +} diff --git a/apps/server/web/src/components/presets/preset-table-row.tsx b/apps/server/web/src/components/presets/preset-table-row.tsx index ef023e28b..745c6153c 100644 --- a/apps/server/web/src/components/presets/preset-table-row.tsx +++ b/apps/server/web/src/components/presets/preset-table-row.tsx @@ -4,6 +4,7 @@ import { useId, useState } from "react"; import { type ActionItem, ActionsMenu } from "@/components/actions-menu"; import { CopyButton } from "@/components/copy-button"; import { presetLaunchCommand } from "@/lib/launch-command"; +import { ownsPreset } from "@/lib/preset-access"; import type { PresetRow } from "@/types/preset"; /** @@ -44,6 +45,11 @@ export function PresetTableRow({ preset, binary, items }: { preset: PresetRow; b {shown ? : } {preset.name} + {!ownsPreset(preset) && ( +

+ Shared by {preset.ownerName ?? preset.userId} · {preset.access === "view" ? "View" : "Edit"} +

+ )} {preset.restartOnExit === 1 ? "Yes" : "No"} @@ -52,6 +58,12 @@ export function PresetTableRow({ preset, binary, items }: { preset: PresetRow; b {isPresetCrossCommReady(preset) ? "Yes" : "No"} + + {preset.ownerName ?? (ownsPreset(preset) ? "You" : preset.userId)} +
@@ -64,7 +76,7 @@ export function PresetTableRow({ preset, binary, items }: { preset: PresetRow; b while the row claims it is hidden. */} {shown && ( - +
               {command}
             
diff --git a/apps/server/web/src/components/presets/ssh-preset-editor.tsx b/apps/server/web/src/components/presets/ssh-preset-editor.tsx index 10d26c858..4b8985810 100644 --- a/apps/server/web/src/components/presets/ssh-preset-editor.tsx +++ b/apps/server/web/src/components/presets/ssh-preset-editor.tsx @@ -1,9 +1,10 @@ -import { ApiError, errMessage } from "@internal/node-admin"; +import { ApiError, Button, errMessage } from "@internal/node-admin"; import { useState } from "react"; import { useBrowserKeySelection } from "@/components/connect/browser-key-fields"; import { isSshResolveRefusal, refusalFromOutcome } from "@/components/connect/ssh-refusal"; import { sshSessionDraft } from "@/components/connect/ssh-session-draft"; import { SshWizard } from "@/components/connect/ssh-wizard"; +import { PresetDetails } from "@/components/presets/preset-details"; import { SshPresetNameField } from "@/components/presets/ssh-preset-name-field"; import { Dialog, @@ -15,6 +16,8 @@ import { } from "@/components/ui/dialog"; import { useSshReadiness } from "@/hooks/use-ssh"; import { useSaveSshPreset } from "@/hooks/use-ssh-presets"; +import { canEditPreset } from "@/lib/preset-access"; +import { isDuplicatePresetName } from "@/lib/preset-errors"; import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset"; import type { SshPreset } from "@/types/preset"; @@ -34,7 +37,34 @@ function presetSaveRefusal(error: unknown): string { } /** A fresh mount is one editing session, including clones. No credential is requested here. */ -export function SshPresetEditor({ +export function SshPresetEditor(props: { preset?: SshPreset; cloneName?: string; onClose: () => void }) { + if (props.preset && props.cloneName === undefined && !canEditPreset(props.preset)) { + return ( + { + if (!open) props.onClose(); + }} + > + + + View SSH preset + + You have view access. You can use or clone this preset, but you can't change it. + + + + + + + ); + } + return ; +} + +function EditableSshPreset({ preset, cloneName, onClose, @@ -51,7 +81,7 @@ export function SshPresetEditor({ const readiness = useSshReadiness(); const browserKey = useBrowserKeySelection(); const save = useSaveSshPreset(); - const duplicateName = save.error instanceof ApiError && save.error.code === "duplicate"; + const duplicateName = isDuplicatePresetName(save.error); const editing = !!preset && cloneName === undefined; return ( (null); const [editor, setEditor] = useState<{ preset: SshPreset; cloneName?: string } | null>(null); const [error, setError] = useState(null); function clone(preset: SshPreset) { @@ -58,6 +62,7 @@ export function SshPresetLibrary({ } return ( <> + {sharing && setSharing(null)} />} {creating && } {editor && setEditor(null)} />} {error && ( @@ -97,6 +102,9 @@ export function SshPresetLibrary({ Authentication + + Owner + Actions @@ -117,6 +125,11 @@ export function SshPresetLibrary({ > {preset.name} + {!ownsPreset(preset) && ( +

+ Shared by {preset.ownerName ?? preset.userId} · {preset.access === "view" ? "View" : "Edit"} +

+ )} {preset.destination} @@ -129,6 +142,12 @@ export function SshPresetLibrary({ {SSH_AUTHENTICATION_LABELS[preset.authentication]} + + {preset.ownerName ?? (ownsPreset(preset) ? "You" : preset.userId)} + void navigate({ to: "/new", search: { kind: "ssh", preset: preset.id } }), }, - { label: "Edit", icon: Pencil, onSelect: () => setEditor({ preset }) }, - { label: "Clone preset", icon: Copy, onSelect: () => clone(preset) }, { - label: "Delete preset", - icon: Trash2, - destructive: true, - onSelect: () => void remove(preset), + label: canEditPreset(preset) ? "Edit" : "View preset", + icon: Pencil, + onSelect: () => setEditor({ preset }), }, + { + label: "Export preset", + icon: Download, + onSelect: () => { + try { + downloadPreset(preset); + } catch (error) { + setError(errMessage(error, "Couldn't export this preset.")); + } + }, + }, + { label: "Clone preset", icon: Copy, onSelect: () => clone(preset) }, + ...(ownsPreset(preset) + ? [ + { + label: "Share preset", + icon: Share2, + onSelect: () => setSharing(preset.id), + }, + { + label: "Delete preset", + icon: Trash2, + destructive: true, + onSelect: () => void remove(preset), + }, + ] + : []), ]} /> diff --git a/apps/server/web/src/components/sharing-dialog-core.tsx b/apps/server/web/src/components/sharing-dialog-core.tsx index 21462ab59..8188cf4f0 100644 --- a/apps/server/web/src/components/sharing-dialog-core.tsx +++ b/apps/server/web/src/components/sharing-dialog-core.tsx @@ -26,14 +26,16 @@ function ShareLevelSelect({ label, value, onChange, + disabled = false, }: { label: string; + disabled?: boolean; value: ShareLevel; onChange: (value: ShareLevel) => void; }) { return ( - level && onChange(level)}> + @@ -78,6 +80,7 @@ export function SharingDialogCore({ onSave, canManage = true, warning, + loadError, }: { /** Whether the dialog is shown */ open: boolean; @@ -102,6 +105,8 @@ export function SharingDialogCore({ * share hands over a machine. */ warning?: ReactNode; + /** Failed initial reads must not be mistaken for an empty grant set. */ + loadError?: ReactNode; /** PUTs the complete replacement set; rejects with the failure to surface */ onSave: (grants: ShareDraft[]) => Promise; /** False renders everything read-only (no add/remove/level/Save) */ @@ -115,6 +120,8 @@ export function SharingDialogCore({ const [pickLevel, setPickLevel] = useState("view"); const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const busy = saving || submitting; const pickerId = useId(); const grants = draft ?? serverGrants; // Grant order isn't meaningful: removing and re-adding the same access is unchanged. @@ -122,7 +129,7 @@ export function SharingDialogCore({ const hasChanges = grants.length !== serverGrants.length || grants.some((grant) => savedPermissions.get(grant.granteeUserId) !== grant.permission); - const saveDisabled = saving || isLoading || !hasChanges; + const saveDisabled = busy || isLoading || !!loadError || !hasChanges; const alreadyShared = new Set(grants.map((g) => g.granteeUserId ?? EVERYONE)); const recipientOptions = [ @@ -132,6 +139,7 @@ export function SharingDialogCore({ const selectedRecipient = recipientOptions.some((option) => option.value === pickId) ? pickId : ""; function handleClose(next: boolean) { + if (!next && busy) return; if (!next) { setDraft(null); setError(null); @@ -140,15 +148,17 @@ export function SharingDialogCore({ } function changeLevel(index: number, level: ShareLevel) { + if (isLoading || loadError || busy) return; setDraft(grants.map((g, i) => (i === index ? { ...g, permission: level } : g))); } function removeAt(index: number) { + if (isLoading || loadError || busy) return; setDraft(grants.filter((_, i) => i !== index)); } function addGrant() { - if (!selectedRecipient) return; + if (!selectedRecipient || isLoading || loadError || busy) return; const granteeUserId = selectedRecipient === EVERYONE ? null : selectedRecipient; // Replace any existing grant to the same target rather than duplicating. setDraft([...grants.filter((g) => g.granteeUserId !== granteeUserId), { granteeUserId, permission: pickLevel }]); @@ -158,11 +168,15 @@ export function SharingDialogCore({ async function save() { if (!canManage || saveDisabled) return; setError(null); + setSubmitting(true); try { await onSave(grants); - handleClose(false); + setDraft(null); + onOpenChange(false); } catch (err) { setError(errMessage(err, "The sharing settings could not be saved.")); + } finally { + setSubmitting(false); } } @@ -177,6 +191,7 @@ export function SharingDialogCore({ {/* Before the grant rows, not after: the disclosure has to be read while deciding, not discovered under the Save button. */} {warning != null && {warning}} + {loadError}
{canManage && ( @@ -185,7 +200,7 @@ export function SharingDialogCore({

)} {isLoading &&

Loading…

} - {!isLoading && grants.length === 0 && ( + {!isLoading && !loadError && grants.length === 0 && (

{hasChanges ? "No one will have shared access after you save." : "Not shared with anyone yet."}

@@ -200,10 +215,11 @@ export function SharingDialogCore({ ? "Everyone" : (roster.find((u) => u.id === g.granteeUserId)?.email ?? g.granteeUserId)} - {canManage ? ( + {canManage && !isLoading && !loadError ? ( <> changeLevel(i, level)} /> @@ -211,6 +227,8 @@ export function SharingDialogCore({ variant="ghost" size="sm" className="h-9" + disabled={busy} + disabledReason="Wait for sharing changes to save." onClick={() => removeAt(i)} aria-label={`Remove access for ${g.granteeUserId === null ? "Everyone" : (roster.find((u) => u.id === g.granteeUserId)?.email ?? "this user")}`} > @@ -225,9 +243,9 @@ export function SharingDialogCore({
))} - {canManage && ( + {canManage && !isLoading && !loadError && (
-
+
@@ -239,15 +257,20 @@ export function SharingDialogCore({ options={recipientOptions} emptyText="No matching people to add." /> -
- + + @@ -258,7 +281,12 @@ export function SharingDialogCore({
- {canManage && ( @@ -266,14 +294,16 @@ export function SharingDialogCore({ onClick={() => void save()} disabled={saveDisabled} disabledReason={ - saving + busy ? "Wait for sharing changes to save." - : isLoading - ? "Wait for the current sharing settings to load." - : "No sharing changes to save." + : loadError + ? "Retry loading the sharing settings before saving." + : isLoading + ? "Wait for the current sharing settings to load." + : "No sharing changes to save." } > - {saving ? "Saving…" : "Save"} + {busy ? "Saving…" : "Save"} )} diff --git a/apps/server/web/src/components/subshell-actions-menu.tsx b/apps/server/web/src/components/subshell-actions-menu.tsx index 4985c9f64..b329c18e2 100644 --- a/apps/server/web/src/components/subshell-actions-menu.tsx +++ b/apps/server/web/src/components/subshell-actions-menu.tsx @@ -28,6 +28,7 @@ import { TitleDialog } from "@/components/ui/title-dialog"; import { usePresets } from "@/hooks/use-presets"; import { useSubshellMutations } from "@/hooks/use-subshell-mutations"; import { desktopInvoke, isDesktop } from "@/lib/desktop"; +import { canEditPreset } from "@/lib/preset-access"; import type { SubshellView } from "@/types/subshell"; /** @@ -273,7 +274,7 @@ export function SubshellActionsMenu({ }, ] : []), - ...(preset + ...(preset && canEditPreset(preset) ? [ { icon: SlidersHorizontal, diff --git a/apps/server/web/src/components/subshell-picker/launch-command-preview.tsx b/apps/server/web/src/components/subshell-picker/launch-command-preview.tsx index 4ce2324ce..0cd1af524 100644 --- a/apps/server/web/src/components/subshell-picker/launch-command-preview.tsx +++ b/apps/server/web/src/components/subshell-picker/launch-command-preview.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from "lucide-react"; import { useId, useState } from "react"; import { EditPresetDialog } from "@/components/presets/edit-preset-dialog"; import { presetLaunchCommand } from "@/lib/launch-command"; +import { canEditPreset } from "@/lib/preset-access"; import type { PresetRow } from "@/types/preset"; /** The preset's launch contribution, revealed only when the person asks. */ @@ -58,7 +59,7 @@ export function LaunchCommandPreview({ )} - {onApplyPreset && ( + {onApplyPreset && canEditPreset(preset) && (

To change this command,{" "} + } + /> + ) : undefined + } serverGrants={serverGrants} roster={roster.data ?? []} saving={setShares.isPending} diff --git a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx index 327afab75..d5578833e 100644 --- a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx @@ -99,7 +99,7 @@ function stubApi() { : // SetupKeysSection reads `data?.keys.length` — an empty object // here took the whole route down behind the error boundary in the // gate test's first draft. - { keys: [] }; + { keys: [], shares: [], users: [] }; return Promise.resolve( new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }), ); @@ -200,3 +200,20 @@ describe("/nodes toolbar filters", () => { } }); }); + +it("opens sharing directly from a node's Share action and keeps the list visible", async () => { + const restore = stubApi(); + try { + renderNodes(); + const menu = await screen.findByRole("button", { name: "Actions for AI PC" }); + fireEvent.click(menu); + fireEvent.click(await screen.findByRole("menuitem", { name: "Share" })); + await screen.findByRole("dialog", { name: "Share node" }); + expect(screen.getByRole("heading", { name: "Nodes", hidden: true })).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByRole("dialog") === null).toBe(true)); + expect(screen.getByRole("link", { name: "AI PC" })).toBeDefined(); + } finally { + restore(); + } +}); diff --git a/apps/server/web/src/routes/nodes.tsx b/apps/server/web/src/routes/nodes.tsx index 410fa4df9..03f907bc2 100644 --- a/apps/server/web/src/routes/nodes.tsx +++ b/apps/server/web/src/routes/nodes.tsx @@ -9,6 +9,7 @@ import { EmptyState } from "@/components/empty-state"; import { ErrorBanner } from "@/components/error-banner"; import { LoadingCard } from "@/components/loading-card"; import { AddNodeDialog } from "@/components/nodes/add-node-dialog"; +import { NodeSharingDialog } from "@/components/nodes/node-sharing-dialog"; import { NodeTable } from "@/components/nodes/node-table"; import { SetupKeysSection } from "@/components/nodes/setup-keys-section"; import { PageHeader } from "@/components/page-header"; @@ -42,6 +43,7 @@ function NodesPage() { const active: NodesTab = tab ?? "list"; const deleteNode = useDeleteNode(); const [dialogOpen, setDialogOpen] = useState(false); + const [sharingId, setSharingId] = useState(null); // Whether to OFFER adding a node — `lib/node-enrollment.ts` carries the // rule and why the unknown case reads as allowed. The route is the gate. const { data: publicSettings } = usePublicSettings(); @@ -64,6 +66,7 @@ function NodesPage() { { refetchInterval: dialogOpen ? 3000 : false }, ); const visible = results.data?.nodes ?? []; + const sharingNode = nodes.find((node) => node.id === sharingId); /** The node's config page (`/nodes/$id`, spec §10). */ function goDetail(id: string) { @@ -258,7 +261,7 @@ function NodesPage() { void remove(node)} onError={setActionError} /> @@ -266,6 +269,17 @@ function NodesPage() { )} + {sharingNode && ( + { + if (!open) setSharingId(null); + }} + /> + )} );