Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/document-preset-sharing.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/shared-preset-library.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/presets/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 42 additions & 1 deletion apps/docs/content/docs/presets/use.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
229 changes: 229 additions & 0 deletions apps/server/api/src/api/__tests__/preset-sharing.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(response: Response): Promise<T> {
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<PresetView[]>(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<PresetView[]>(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<PresetView>(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<PresetView[]>(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<PresetView[]>(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");
});
6 changes: 5 additions & 1 deletion apps/server/api/src/api/__tests__/presets-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions apps/server/api/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
Loading
Loading