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
37 changes: 34 additions & 3 deletions apps/app/src/channel-access.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { type ReactNode, useState } from "react";

import type { Listing } from "@ace/host/protocol";
import { Button, toast, Tooltip, TooltipContent, TooltipTrigger } from "@ace/ui";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
toast,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@ace/ui";
import { IconBot, IconMonitor } from "@ace/ui/icons";

import { useDetails } from "./details";
Expand Down Expand Up @@ -51,11 +63,13 @@ function AccessToggle({ label, value, live, owner, pending, description, childre
export function ChannelAccess({ channel, user }: { channel: Listing; user: string }) {
const { shared, sharedLive, desktop, desktopLive } = useDetails(channel.id);
const [sharing, setSharing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [changingDesktop, setChangingDesktop] = useState(false);
const owner = channel.owner === user;

// Render only watch-confirmed values, never an optimistic permission grant.
function share() {
setConfirming(false);
setSharing(true);
host.channel(channel.id, { op: "share", author: user, shared: !shared }).catch(
(error: Error) =>
Expand Down Expand Up @@ -83,8 +97,8 @@ export function ChannelAccess({ channel, user }: { channel: Listing; user: strin
live={sharedLive}
owner={owner}
pending={sharing}
description="Allow collaborators to invoke agents. Turning this off leaves chat available and active runs continue."
onClick={share}
description="Allow collaborators to invoke agents, which run commands as you on this machine. Turning this off leaves chat available and active runs continue."
onClick={() => shared ? share() : setConfirming(true)}
>
<IconBot aria-hidden />
</AccessToggle>
Expand All @@ -99,6 +113,23 @@ export function ChannelAccess({ channel, user }: { channel: Listing; user: strin
>
<IconMonitor aria-hidden />
</AccessToggle>
<Dialog open={confirming} onOpenChange={setConfirming}>
<DialogContent className="gap-3 sm:max-w-80" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Let collaborators invoke agents?</DialogTitle>
<DialogDescription>
Teammates can run commands as you on this machine.
{desktop && " Desktop tools are on, so they can also operate your apps."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConfirming(false)}>
Cancel
</Button>
<Button type="button" onClick={share}>Turn on</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
11 changes: 8 additions & 3 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,14 @@ async function main() {
`${moved.name} now runs ${moved.hosted ? `at ${moved.hosted}` : "on this host"}`,
);
}
case "share":
await request(record.id, { op: "share", author: catalog.user, shared: rest[0] === "on" });
return;
case "share": {
const shared = rest[0] === "on";
await request(record.id, { op: "share", author: catalog.user, shared });
if (!shared) return;
return console.log(
`Sharing is on for ${record.name}. Teammates can run commands as you on this machine.`,
);
}
case "archive":
return void (await archive(record.id, true));
case "unarchive":
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@ no separate accounts, invitations, roles, or team access controls. Every human a
Tailscale login, so a person is the same participant on every host.

The channel owner can turn collaborator agent invocation on or off with `ace share <channel> on|off`
or the channel header's agent-access toggle; channels start shared. It prevents new collaborator
or the channel header's agent-access toggle. New channels start unshared; channels created before
that keep their stored setting, and those without one stay shared. Turning sharing on states that
teammates can run commands as the owner on this host. Turning it off prevents new collaborator
invocations; active work continues until stopped or killed, and teammates keep reading and posting
human messages. The teammate composer holds an invoking draft instead of sending it while sharing
is off.
Expand Down
10 changes: 9 additions & 1 deletion docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ switch names a base.

Channel access settings live in pi's version 2 `ace.settings` session document. `shared` controls
new collaborator agent invocations; `desktop` independently gates native desktop tools for every
chat and participant in the channel. A missing document defaults both to enabled. pi migrates
chat and participant in the channel. New channels store the document at creation with `shared:
false`; an existing channel's missing document still means both are enabled. pi migrates
version 1 documents on read, preserving `shared` (including `false`) and adding `desktop: true`
for compatibility. The version 2 form is written on the next settings change. Neither migration
nor changing a setting rewrites chats, entries, or name/summary revisions; these gates are not a
Expand Down Expand Up @@ -134,3 +135,10 @@ confirmed desktop refusals in the owner's chat, a subagent, and an active collab
second chat; re-enabling allowed a real native application inventory while sharing remained off.
All 175 original entries across both backups stayed byte-for-byte equivalent, both databases
passed integrity checks, and the source backup checksums stayed unchanged.

The October 14, 2026 unshared-default check created two channels with the previous build in an
isolated `ACE_HOME`: one never changed (no stored settings) and one turned unshared. With the new
build they reopened shared and unshared respectively, and the unshared channel's documents and
revisions matched its backup. Turning desktop tools off on the channel without stored settings
kept it shared across a worker restart. A new channel stored `shared: false` at creation, refused a
teammate's agent invocation, and kept the setting across a restart.
16 changes: 13 additions & 3 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ const SettingsDoc = defineDoc<Settings>({
kind: "ace.settings",
version: 2,
scope: "session",
initial: () => ({ shared: true, desktop: true }),
initial: () => ({ shared: false, desktop: true }),
migrate: (value) => ({ shared: value.shared as boolean, desktop: true }),
});

/** Channels created before new channels started unshared store no settings and stay shared. */
const LEGACY: Settings = { shared: true, desktop: true };

/** Inactivity is measured in hours, so activity is published at most this often. */
const ACTIVITY_INTERVAL = 5 * 60_000;

Expand Down Expand Up @@ -168,6 +171,10 @@ export class Channel {
env: ({ cwd }) => options.env(cwd || options.project),
onReport: (error) => log("error", "harness.report", failure(error)),
}, context);
// Only a new channel stores its settings at creation; an existing one without them is legacy.
if (!await harness.conversation(ROOT_CONVERSATION_ID, context)) {
await harness.commit(async (tx) => void await tx.doc(SettingsDoc), context);
}
await harness.root(context, { agent: { model: options.model, cwd: options.project } });
const value = await harness.commit(async (tx) => {
const doc = await tx.doc(MetadataDoc);
Expand All @@ -184,7 +191,7 @@ export class Channel {
harness,
log,
value,
settings || SettingsDoc.definition.initial(),
settings || LEGACY,
);
channel.#changed(value);
// Scheduling has not resumed and no caller has this channel yet, so the initial snapshot
Expand Down Expand Up @@ -537,7 +544,10 @@ export class Channel {
}
if (typeof request.enabled !== "boolean") throw new Error("enabled must be a boolean");
await this.#harness.commit(async (tx) => {
(await tx.doc(SettingsDoc)).desktop = request.enabled;
const doc = await tx.doc(SettingsDoc);
// A legacy channel's first write would otherwise store `initial`'s unshared default.
doc.shared = this.#settings.shared;
doc.desktop = request.enabled;
}, context);
}

Expand Down
9 changes: 5 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ your own machines.
Ace has no separate accounts, invitations, or team access controls. Only allow people and machines
you trust with access to your host to reach Ace over your tailnet.

Channels start with collaborator agent invocation enabled. Treat sharing a channel as granting
teammates the ability to run commands on its host. `bun ace share <channel> off` blocks new
collaborator invocations; active work continues, and teammates can still read channel history and
post messages. Disabling native desktop tools does not restrict shell access.
New channels start with collaborator agent invocation off: teammates can read channel history and
post messages, but not invoke agents. Turning it on with `bun ace share <channel> on` or the
channel header lets teammates run commands as you on its host. `bun ace share <channel> off`
blocks new collaborator invocations; active work continues. Disabling native desktop tools does
not restrict shell access.

Keep the host's gateway on loopback or your private tailnet. **Do not expose it to the public
internet** through Tailscale Funnel, port forwarding, or a public reverse proxy. Protect local
Expand Down