diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index c0e1fa9c..c2f7ca08 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -219,8 +219,8 @@
"packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268",
"packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78",
"packages/app/src/pages/overlay-content-hash.test.ts": "aa208098213522dcde42434408afe9c8641fad663f44a58c316922dbd0da2f5c",
- "packages/app/src/pages/session.tsx": "d4ad8e9d652f99f9112e220b47dda9b2b9f8e44180cf53924506a2019f326e9e",
- "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f",
+ "packages/app/src/pages/session.tsx": "fcfb48b1b92ce96063eb71b78d343e08a7cf24110a8583a3e1c205d5d56d412f",
+ "packages/app/src/utils/amicode-bridge.ts": "3ec87c24c0384d312f7eb644807d9217a53b1b4b97f7fffadab1cc1595603f3b",
"packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444",
"packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645",
"packages/app/src/utils/amicode-developer.ts": "b5cecd598b7e50631d2a441d7c72d1a012a2a77bdee3629fcfe9400a36312ad6",
@@ -632,7 +632,7 @@
"packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062",
"packages/app/src/components/prompt-input/submit.ts": "6f14368b0d481bdf10a2711e08da41010edd9f67ca8af90b930c096a56778b89",
"packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019",
- "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4",
+ "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8",
"packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0",
"packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd",
"packages/app/src/components/session/pdf-restore.test.ts": "4cd4cabbdc8063deb5fe95b5e637cf154ccdf864c5ff9f2f6833826bd69e4f90",
@@ -644,9 +644,9 @@
"packages/app/src/components/session/session-chats-dropdown.test.ts": "b5f178bea1b52788d6f794d09ccf7ff81f745633ad59e2d438cfc80b96acde55",
"packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81",
"packages/app/src/components/session/session-fleet-peers.test.ts": "e81601e8a5acba4def10f7aa5654c9125c363a2bddfd5a3aa9b72cbcdb9a5a44",
- "packages/app/src/components/session/session-fleet-peers.ts": "47bc20deed958a7eead1c445e442d396ad83ca4a7bc2f33ebc04d77cdd5fcb0a",
- "packages/app/src/components/session/session-header.test.tsx": "c41252d81ca29491a030c56bbe41906017d44992795fea8d95df543bfc024a95",
- "packages/app/src/components/session/session-header.tsx": "ac550119c39bf603a096fc20052f7657e0e0fcb891979c30d4a6c15c75cd46a5",
+ "packages/app/src/components/session/session-fleet-peers.ts": "41befcc0fae2d77821c0cb64ce551e0768e3eb6ab7e2d738aee649c680b4fead",
+ "packages/app/src/components/session/session-header.test.tsx": "4504833008e9677a70d49952d81721d8dd58aff9af3aafd1cc3eee98d669faef",
+ "packages/app/src/components/session/session-header.tsx": "94432e8948f93ce40a15e5d5cd376bb888cccf54c15a2ef15ff49ce9de499f26",
"packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57",
"packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe",
"packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653",
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
index 92b19497..3ac8c2da 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
@@ -1,4 +1,4 @@
-export { SessionHeader } from "./session-header"
+export { SessionHeader, SessionComposerControlScrim } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SessionPreviewTab } from "./session-preview-tab"
export { PanelMenu } from "./panel-menu"
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts
index 164a006b..ced72de1 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts
@@ -199,6 +199,14 @@ export function drivingBannerFromProjection(raw: unknown, sessionId: string): {
return drivingBanner(findSessionEntryInProjection(raw, sessionId))
}
+/** The owner tag for the CURRENT session id, read off the fleet projection — the
+ * composer scrim's source for the peer NAME (the CTA copy) and the owner
+ * machineId (the enable-control envelope's target). Unknown id / local /
+ * garbage → undefined. Tolerant (never throws). */
+export function findSessionOwnerInProjection(raw: unknown, sessionId: string): SessionOwnerTag | undefined {
+ return readOwnerTag(findSessionEntryInProjection(raw, sessionId)?.amicode_owner)
+}
+
/** True when a session is a REMOTE peer session (has an owner overlay whose
* `is_local` is explicitly false). Local / unowned sessions are false —
* absence of an overlay means local (ADR 0031 §D6). B1 uses this to keep
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx
index bcad8052..762208ef 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx
@@ -19,12 +19,31 @@ describe("#1544 session-header control wiring", () => {
expect(source).toContain("drivingBannerFromProjection(controlProjection.latest")
})
- test("Enable control dispatches the VS Code native-modal confirm (ADR 0034 D4)", () => {
- expect(source).toContain("postAmicode(ENABLE_CONTROL_COMMAND)")
- expect(source).toContain('data-action="session-enable-control"')
- // the affordance is derived from eligibility (enable vs request vs none)
- expect(source).toContain("controlAffordance(")
- expect(source).toContain("data-control-affordance={controlAffordanceState().kind}")
+ test("Enable control is re-homed onto the composer scrim, NOT a fixed top-right Portal (#1551 DEFECT 2)", () => {
+ // The old placement (a Portal to document.body at top:8px/right:12px over
+ // the titlebar) is REMOVED — no top-right affordance, no command-lane post.
+ expect(source).not.toContain('data-action="session-enable-control"')
+ expect(source).not.toContain('top: "8px"')
+ expect(source).not.toContain("postAmicode(ENABLE_CONTROL_COMMAND)")
+ expect(source).not.toContain("ENABLE_CONTROL_COMMAND")
+ })
+
+ test("the composer-anchored scrim gates the composer + centers the CTA when control is not held (#1551)", () => {
+ // The affordance now lives on a composer-anchored scrim.
+ expect(source).toContain("export function SessionComposerControlScrim")
+ expect(source).toContain('data-slot="amicode-composer-control-scrim"')
+ expect(source).toContain('data-action="composer-enable-control"')
+ // the composer subtree is BLURRED + made non-editable (inert) while gated
+ expect(source).toContain("blur(")
+ expect(source).toContain("inert")
+ // gating is derived from the shared control-state reads (held vs not held) on
+ // a remote peer session — never the titlebar
+ expect(source).toContain("isControlHeld(")
+ expect(source).toContain("isRemotePeerSession(")
+ // the CTA names the peer the session lives on
+ expect(source).toContain("This session lives on")
+ // the CTA click posts DEFECT 1's payload envelope (not the retired command lane)
+ expect(source).toContain("postAmicodeFleetEnableControl(")
})
test("a not-held remote row shows a disabled reason chip (never a live erroring button)", () => {
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
index 27034971..8c8ac441 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
@@ -11,7 +11,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { getFilename } from "@opencode-ai/core/util/path"
-import { batch, createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { batch, createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
@@ -42,7 +42,7 @@ import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpe
import { useNavigate } from "@solidjs/router"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { amicodeGet } from "@/utils/amicode-fetch"
-import { postAmicode } from "@/utils/amicode-bridge"
+import { postAmicodeFleetEnableControl } from "@/utils/amicode-bridge"
import {
peerSessionsFromProjection,
mergePeerSessions,
@@ -53,19 +53,22 @@ import {
writeAffordanceEnabled,
failClosedChip,
controlAffordance,
+ isControlHeld,
drivingBannerFromProjection,
findSessionControlInProjection,
+ findSessionOwnerInProjection,
remoteDeleteAction,
type DropdownSession,
} from "./session-fleet-peers"
-// AMICODE #1544 (slice 4): the app→extension bridge command that opens the VS
-// Code NATIVE-MODAL confirm for enabling control of a self-owned peer (ADR 0034
-// D4 — the one net-new confirmation surface). The extension relays it to the
-// modal + the #1541 self-owned control issuance; on success the projected state
-// flips to `interactive` and the driving banner lights. Present-and-dispatching
-// here; the modal + issuance handler is the extension-side seam.
-const ENABLE_CONTROL_COMMAND = "amicode.fleet.enableControl"
+// AMICODE #1551 (DEFECT 2): the self-owned Enable-control affordance no longer
+// lives as a fixed top-right Portal over the titlebar. It is re-homed onto a
+// COMPOSER-ANCHORED scrim (SessionComposerControlScrim, below) that blurs +
+// gates the composer and centers the CTA while control is not held. The CTA
+// posts the #1551 payload envelope (postAmicodeFleetEnableControl); the extension
+// host shows the ADR 0034 D4 native modal and mints the grant. On success the
+// next fleet-projection poll flips the projected control to `interactive` — the
+// app never self-declares interactive.
// AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator
// noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is
@@ -209,17 +212,6 @@ export function SessionHeader() {
const drivingPeer = createMemo(() =>
params.id ? drivingBannerFromProjection(controlProjection.latest, params.id) : null,
)
- const controlAffordanceState = createMemo(() =>
- controlAffordance(findSessionControlInProjection(controlProjection.latest, params.id ?? "")),
- )
- const enableControl = () => {
- // The one net-new confirmation surface: dispatch the VS Code native-modal
- // confirm (ADR 0034 D4). The extension relays it to the modal + #1541
- // self-owned issuance; on success the projected state flips to interactive
- // and the driving banner lights. Request-control (shared) is inert here —
- // its request→approve backend is #1545.
- if (controlAffordanceState().kind === "enable-control") postAmicode(ENABLE_CONTROL_COMMAND)
- }
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -414,42 +406,9 @@ export function SessionHeader() {
)}
- {/* #1544: the Enable-control (self-owned) / Request-control (shared)
- affordance on the session surface. Enable → the native-modal confirm
- (ADR 0034 D4). Request → present-but-inert (backend is #1545). */}
-
-
-
-
-
+ {/* #1551 (DEFECT 2): the Enable-control affordance moved OFF the titlebar
+ and onto the composer scrim (SessionComposerControlScrim, below). No
+ fixed top-right Portal renders here anymore. */}
{(mount) => (
@@ -751,6 +710,119 @@ export function SessionHeader() {
)
}
+// #1551 (DEFECT 2): the composer-anchored control scrim. When the current
+// session is a REMOTE peer session and control is NOT held, it BLURS + GATES the
+// composer (inert → non-editable) and centers a CTA card naming the peer the
+// session lives on, with the Enable-control button. When control is held
+// (interactive) or the session is local/unowned, the composer renders untouched
+// (no scrim). The CTA click posts the #1551 payload envelope
+// (postAmicodeFleetEnableControl); the extension host shows the ADR 0034 D4
+// native modal and mints the grant. On success the next fleet-projection poll
+// flips the projected control to `interactive` — the app never self-declares it.
+export function SessionComposerControlScrim(props: { children: JSX.Element }) {
+ const server = useServer()
+ const { params } = useSessionLayout()
+ // Tolerant read of the control channel for the CURRENT session (the SAME
+ // carrier the header + dropdown consume). A 404 / no-fleet resolves to
+ // undefined and every derived value degrades to "no scrim".
+ const [controlProjection] = createResource(
+ () => server.current,
+ (conn) => amicodeGet(conn, "/amicode/fleet/sessions").catch(() => undefined),
+ )
+ const control = createMemo(() =>
+ findSessionControlInProjection(controlProjection.latest, params.id ?? ""),
+ )
+ const owner = createMemo(() => findSessionOwnerInProjection(controlProjection.latest, params.id ?? ""))
+ // Gated iff a REMOTE peer session whose control is NOT held (read-only /
+ // suspended). Local / unowned / already-driving (interactive) → not gated.
+ const gated = createMemo(() => isRemotePeerSession({ amicode_owner: owner() }) && !isControlHeld(control()))
+ const peerLabel = createMemo(() => owner()?.owner_name || owner()?.owner_machine_id || "this peer")
+ const enableControl = () => {
+ const o = owner()
+ const id = params.id
+ if (!o || !id) return
+ // The CTA click path is identical to DEFECT 1's envelope.
+ postAmicodeFleetEnableControl({ ownerMachineId: o.owner_machine_id, sessionID: id })
+ }
+ return (
+
+ {/* The composer subtree — blurred + inert (non-editable, unfocusable)
+ while gated; untouched otherwise. */}
+
+ {props.children}
+
+
+
+
+
+
+ This session lives on {peerLabel()} — Enable control to drive it
+
+
+
+
+
+
+ )
+}
+
type SessionHeaderV2ActionsState = {
/** AMICODE (#174 AC2): the trigger itself always renders in a session; the
* show-status setting only drives this health-dot flag. */
diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx
index 6d9ddace..3f723ade 100644
--- a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx
@@ -37,7 +37,7 @@ import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
-import { NewSessionView, SessionHeader } from "@/components/session"
+import { NewSessionView, SessionHeader, SessionComposerControlScrim } from "@/components/session"
import { ContextWarningBanner } from "@/components/session/context-warning-banner"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
@@ -2668,6 +2668,7 @@ export default function Page() {
}}
+
}
/>
)
diff --git a/packages/app-bundle/overlay/packages/app/src/utils/amicode-bridge.ts b/packages/app-bundle/overlay/packages/app/src/utils/amicode-bridge.ts
index 9f53f270..48c185c3 100644
--- a/packages/app-bundle/overlay/packages/app/src/utils/amicode-bridge.ts
+++ b/packages/app-bundle/overlay/packages/app/src/utils/amicode-bridge.ts
@@ -13,3 +13,18 @@ export const postAmicode = (command: string) => {
window.parent?.postMessage({ source: "amicode", kind: "command", command }, "*")
} catch {}
}
+
+// #1551: the self-owned enable-control act. A PAYLOAD envelope (not the bare
+// command lane, which cannot carry a target): the extension host shows the ADR
+// 0034 D4 native modal and, on confirm, mints the self-owned control grant for
+// the target peer. On success the next fleet-projection poll flips the session's
+// projected control state to `interactive` and the driving banner lights — the
+// app never self-declares interactive (the SoT projection is the source).
+export const postAmicodeFleetEnableControl = (req: { ownerMachineId: string; sessionID: string }) => {
+ try {
+ window.parent?.postMessage(
+ { source: "amicode", kind: "fleet-enable-control", ownerMachineId: req.ownerMachineId, sessionID: req.sessionID },
+ "*",
+ )
+ } catch {}
+}
diff --git a/packages/extension/src/amicode_service/fleet_control_bootstrap.ts b/packages/extension/src/amicode_service/fleet_control_bootstrap.ts
index 79aaace7..2e4a7fe5 100644
--- a/packages/extension/src/amicode_service/fleet_control_bootstrap.ts
+++ b/packages/extension/src/amicode_service/fleet_control_bootstrap.ts
@@ -154,3 +154,82 @@ export function enableSelfOwnedControl(req: EnableControlRequest, deps: Lifecycl
}
return { ok: true, grant: issued.grant };
}
+
+// ── #1551: the LIVE caller — the extension-side enable-control handler ────────
+//
+// enableSelfOwnedControl had ZERO live callers (unit-tested, never invoked). This
+// is the handler the app→extension `fleet-enable-control` envelope drives. It is
+// pure of any vscode import — the NATIVE MODAL is injected as `confirm` (ADR 0034
+// D4 lives in the extension wiring, not here), the management-verified inputs are
+// injected from the live fleet-peer provider + authority resolver, and the grant
+// store is the same injectable one enableSelfOwnedControl mints into. Ordering is
+// deliberate: CONFIRM FIRST (a cancelled modal mints nothing, AC3), then compose
+// establishManagementVerified + enableSelfOwnedControl. A non-self-owned or
+// unverified peer mints nothing (no-privilege-bleed, AC4).
+
+/** The deps the enable-control handler needs, all injectable so the decision path
+ * is testable without vscode / the real fleet stores. */
+export interface EnableControlHandlerDeps {
+ /** This machine's identity — the grant's requester (self-issued). */
+ self: { machineId: string; identityKey: string };
+ /** Resolve the driven peer's identity key (roster fingerprint; machineId
+ * fallback is the wiring's concern). Recorded on the minted grant's target. */
+ targetIdentityKey: (ownerMachineId: string) => string;
+ /** Whether the driven peer is self-owned (the base fast-path) vs shared. The
+ * shared-peer request→approve handshake (#1545) is the only other path. */
+ ownershipOf: (ownerMachineId: string) => PeerOwnership;
+ /** The live serving-peer set (transport up). */
+ getServingPeers: () => Array<{ machineId: string }>;
+ /** The reader peer-token read (bilateral token state). */
+ readPeerToken: (machineId: string) => { ok: boolean };
+ /** The enroll-seeded lifecycle-admin authority resolver. */
+ resolveAuthority: (targetMachineId: string) => LifecycleAuthorityRecord | undefined;
+ /** The ADR 0034 D4 native-modal confirm — resolves true on confirm, false on
+ * cancel. Injected so the modal never runs in-test. */
+ confirm: (ownerMachineId: string) => Promise;
+ /** The grant store deps (default: the real ~/.amico store). */
+ grantDeps?: LifecycleGrantDeps;
+}
+
+export type EnableControlHandlerOutcome =
+ | { outcome: "minted"; grant: LifecycleGrant }
+ | { outcome: "cancelled" }
+ | { outcome: "not-authorized" }
+ | { outcome: "issue-failed"; reason: "identity-mismatch" | "target-mismatch" | "revoked" };
+
+/** Handle one `fleet-enable-control` intent: confirm (native modal) → verify →
+ * mint. Cancelling mints nothing; a shared / unverified peer mints nothing. */
+export async function handleEnableControlRequest(
+ payload: { ownerMachineId: string; sessionID: string },
+ deps: EnableControlHandlerDeps,
+): Promise {
+ const ownerMachineId = payload.ownerMachineId;
+ // CONFIRM FIRST — the ADR 0034 D4 native modal. A cancelled modal mints
+ // nothing and leaves the gated composer unchanged (AC3).
+ const confirmed = await deps.confirm(ownerMachineId);
+ if (!confirmed) return { outcome: "cancelled" };
+
+ const managementVerified = establishManagementVerified({
+ selfMachineId: deps.self.machineId,
+ targetMachineId: ownerMachineId,
+ getServingPeers: deps.getServingPeers,
+ readPeerToken: deps.readPeerToken,
+ resolveAuthority: deps.resolveAuthority,
+ });
+
+ const result = enableSelfOwnedControl(
+ {
+ ownership: deps.ownershipOf(ownerMachineId),
+ managementVerified,
+ self: deps.self,
+ target: { machineId: ownerMachineId, identityKey: deps.targetIdentityKey(ownerMachineId) },
+ },
+ deps.grantDeps,
+ );
+ if (!result.ok) {
+ return result.reason === "requires-approval"
+ ? { outcome: "not-authorized" }
+ : { outcome: "issue-failed", reason: result.issueReason };
+ }
+ return { outcome: "minted", grant: result.grant };
+}
diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts
index 2596c837..aad3578f 100644
--- a/packages/extension/src/chat_bridge.ts
+++ b/packages/extension/src/chat_bridge.ts
@@ -197,6 +197,13 @@ export interface BridgeIo {
* "reset" = expand selected + collapse others, "expand" = expand selected
* only, "none" = highlight only. */
onProjectSelected?: (path: string, mode?: "none" | "expand" | "reset") => void;
+ /** #1551: the self-owned enable-control act. The app posts a
+ * `fleet-enable-control` PAYLOAD envelope carrying the target owner
+ * machineId + sessionID (the bare command lane cannot carry a target); the
+ * host shows the ADR 0034 D4 native modal and, on confirm, mints the
+ * self-owned control grant. Undefined until activation wires it; the kind is
+ * consumed regardless. */
+ onFleetEnableControl?: (req: { ownerMachineId: string; sessionID: string }) => void;
previewVisibleChildren?: (root: string, relativeDirectory: string) => Promise>;
/** Returns the currently-selected Explorer file icon theme as opaque assets. */
explorerIconTheme?: () => ExplorerIconTheme;
@@ -604,6 +611,23 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
return true;
}
+ // #1551: the self-owned enable-control act rides a PAYLOAD envelope, NOT the
+ // command lane — the bare command relay cannot carry the target peer. Shape-
+ // validate { ownerMachineId, sessionID } (both non-empty bounded strings) and
+ // forward to the host's onFleetEnableControl (native modal → self-owned mint).
+ // A malformed payload is consumed and dropped — the callback never fires.
+ if (msg.kind === "fleet-enable-control") {
+ const ownerMachineId = (msg as { ownerMachineId?: unknown }).ownerMachineId;
+ const sessionID = (msg as { sessionID?: unknown }).sessionID;
+ if (
+ typeof ownerMachineId === "string" && ownerMachineId !== "" && ownerMachineId.length <= 200 &&
+ typeof sessionID === "string" && sessionID !== "" && sessionID.length <= 200
+ ) {
+ io.onFleetEnableControl?.({ ownerMachineId, sessionID });
+ }
+ return true;
+ }
+
// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts
index 91b68924..619b987e 100644
--- a/packages/extension/src/chat_panel.ts
+++ b/packages/extension/src/chat_panel.ts
@@ -57,6 +57,10 @@ export class ChatPanel {
* "reset" = expand selected + collapse others, "expand" = expand selected
* only, "none" = highlight only. */
private static onProjectSelectedCallback?: (path: string | null, mode?: "none" | "expand" | "reset") => void;
+ /** #1551: the self-owned enable-control act. Wired at activation to the
+ * extension host's fleet-peer provider + authority resolver + grant store —
+ * the handler shows the ADR 0034 D4 native modal and mints the grant. */
+ private static onFleetEnableControlCallback?: (req: { ownerMachineId: string; sessionID: string }) => void;
private static previewVisibleChildrenCallback?: (root: string, relativeDirectory: string) => Promise>;
/** The `amicode_bug_report=1` boot-param gate (amicode#250 AC5): set from the
* staged skill set after every session prep; the composer button renders
@@ -99,6 +103,12 @@ export class ChatPanel {
ChatPanel.onProjectSelectedCallback = cb;
}
+ /** #1551: subscribe to the self-owned enable-control act (the composer scrim's
+ * CTA posts the `fleet-enable-control` envelope). Wired at activation. */
+ static onFleetEnableControl(cb: ((req: { ownerMachineId: string; sessionID: string }) => void) | undefined): void {
+ ChatPanel.onFleetEnableControlCallback = cb;
+ }
+
static onPreviewVisibleChildren(cb: ((root: string, relativeDirectory: string) => Promise>) | undefined): void {
ChatPanel.previewVisibleChildrenCallback = cb;
}
@@ -215,6 +225,8 @@ export class ChatPanel {
onProjectSelected: ChatPanel.onProjectSelectedCallback
? (p, mode) => { this.lastProjectPath = p; ChatPanel.onProjectSelectedCallback!(p, mode); }
: undefined,
+ // #1551: the enable-control act → the host's modal + self-owned mint.
+ onFleetEnableControl: ChatPanel.onFleetEnableControlCallback,
previewVisibleChildren: ChatPanel.previewVisibleChildrenCallback,
explorerIconTheme: resolveExplorerIconTheme,
});
@@ -596,7 +608,7 @@ export class ChatPanel {
vscode.postMessage(d);
return;
}
- if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex")) {
+ if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
vscode.postMessage(d);
}
return;
@@ -767,7 +779,7 @@ export class ChatPanel {
vscode.postMessage(d);
return;
}
- if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex")) {
+ if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
vscode.postMessage(d);
}
return;
diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts
index 0a5e6550..91be3668 100644
--- a/packages/extension/src/extension.ts
+++ b/packages/extension/src/extension.ts
@@ -86,6 +86,10 @@ import { handleConnectToDevice, type ConnectToDeviceMessage, type FleetConnectDe
import { createFleetFocusHost } from "./fleet_focus";
import { registerAmicodeTerminal } from "./terminal";
import { amicodeServiceDisposal, startAmicodeService, frameOriginUrl } from "./amicode_service_wiring";
+import { buildFleetPeerProvider } from "./amicode_service/fleet_peer_provider";
+import { resolveLifecycleAuthority } from "./amicode_service/fleet_lifecycle_authority";
+import { handleEnableControlRequest } from "./amicode_service/fleet_control_bootstrap";
+import { readRosterRows } from "./amicode_service/roster";
import { resolveAppDistRoot } from "./amicode_service/app_shelf";
import { resolveFleetActivation, type FleetActivationConfig } from "./fleet_activation";
import { recoverBootAttachment } from "./boot_attachment_recovery";
@@ -609,6 +613,59 @@ export async function activate(ctx: vscode.ExtensionContext): Promise {
// behavior: "reset" for explicit selection, "expand" for session/tab switch.
ChatPanel.onProjectSelected((path, mode) => sidebarProvider.setActiveProject(path, mode));
ChatPanel.onPreviewVisibleChildren((root, relativeDirectory) => sidebarProvider.previewVisibleChildren(root, relativeDirectory));
+ // #1551: the self-owned enable-control act. The composer scrim's CTA posts a
+ // `fleet-enable-control` envelope (carrying the target owner machineId +
+ // sessionID); here we show the ADR 0034 D4 native modal and, on confirm, mint
+ // the self-owned control grant via the LIVE fleet-peer provider + enroll-seeded
+ // authority resolver + grant store. Grant persistence is what flips the state:
+ // the next GET /amicode/fleet/sessions poll re-resolves the projected
+ // `amicode_control` to `interactive` and the driving banner lights — the app
+ // never self-declares interactive (the SoT projection is the source of truth).
+ ChatPanel.onFleetEnableControl(async ({ ownerMachineId, sessionID }) => {
+ const selfMachineId = resolveLocalMachineId();
+ if (!selfMachineId) return; // standalone / unenrolled → no self identity, nothing to mint
+ const provider = buildFleetPeerProvider({ localMachineId: selfMachineId });
+ let roster: ReturnType = [];
+ try { roster = readRosterRows(); } catch { roster = []; }
+ // Identity keys (roster fingerprints) recorded on the grant; machineId is the
+ // honest fallback when a row carries no identity_key yet.
+ const identityKeyOf = (machineId: string): string =>
+ roster.find((r) => r.machine_id === machineId)?.identity_key ?? machineId;
+ const peerName = provider.rosterLookup(ownerMachineId)?.name ?? ownerMachineId;
+ const outcome = await handleEnableControlRequest(
+ { ownerMachineId, sessionID },
+ {
+ self: { machineId: selfMachineId, identityKey: identityKeyOf(selfMachineId) },
+ targetIdentityKey: (id) => identityKeyOf(id),
+ // The base is self-owned (the operator owns both machines); the
+ // shared-peer flip to request-control is the #1545 handshake.
+ ownershipOf: () => "self-owned",
+ getServingPeers: () => provider.getServingPeers(),
+ readPeerToken: (id) => ({ ok: provider.readPeerToken(id).ok }),
+ resolveAuthority: (id) => resolveLifecycleAuthority(id),
+ // ADR 0034 D4: the one net-new native-modal confirm.
+ confirm: async (owner) => {
+ const name = provider.rosterLookup(owner)?.name ?? owner;
+ const choice = await vscode.window.showWarningMessage(
+ `Enable control of ${name}?`,
+ {
+ modal: true,
+ detail: "This window will be able to drive that remote session — send prompts and act on your behalf — until you close it.",
+ },
+ "Enable control",
+ );
+ return choice === "Enable control";
+ },
+ },
+ );
+ if (outcome.outcome === "minted") {
+ void vscode.window.showInformationMessage(`Amicode: control enabled — now driving ${peerName}.`);
+ } else if (outcome.outcome === "not-authorized") {
+ void vscode.window.showWarningMessage(
+ `Amicode: couldn't enable control of ${peerName} — the peer isn't reachable or verified.`,
+ );
+ }
+ });
registerOnboardingPanel(ctx); // #433 — Stage 0 model-setup webview
registerHarmoniqsConnectCommand(ctx); // Connect Provider dialog's branded Harmoniqs row
// Heals a provider.harmoniqs entry written by an OLDER extension version
diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts
index ea5a00a8..217d47e9 100644
--- a/packages/extension/test/chat_bridge.test.ts
+++ b/packages/extension/test/chat_bridge.test.ts
@@ -339,6 +339,21 @@ describe("chat panel relay — preview-file in iframe allowlist (#934)", () => {
});
});
+describe("chat panel relay — fleet-enable-control in the app→extension allowlist (#1551)", () => {
+ it("both outer-relay instances forward the enable envelope up to the extension host", () => {
+ const src = fs.readFileSync(
+ path.resolve(__dirname, "..", "src", "chat_panel.ts"),
+ "utf8",
+ );
+ // The inbound (app→extension) relay allowlist must carry fleet-enable-control
+ // in BOTH relay instances (renderHtml + renderTransitionHtml), else the
+ // envelope is dropped before handleAmicodeBridgeMessage sees it.
+ const matches = src.match(/d\.kind === "fleet-enable-control"/g);
+ expect(matches).not.toBeNull();
+ expect(matches!.length).toBeGreaterThanOrEqual(2);
+ });
+});
+
describe("amicode bridge — clipboard", () => {
it("clipboard-request answers with the OS clipboard text and echoes the pane tab", async () => {
const host = io();
@@ -825,6 +840,62 @@ describe("amicode bridge — project-selected (#663)", () => {
});
});
+// ============================================================================
+// #1551 — the enable-control act rides a PAYLOAD envelope (kind:
+// "fleet-enable-control" carrying { ownerMachineId, sessionID }), NOT the
+// command lane: the bare command relay cannot carry the target peer. The
+// handler shape-validates the payload and forwards it to the host's
+// onFleetEnableControl callback (which shows the native modal + mints).
+// ============================================================================
+describe("amicode bridge — fleet-enable-control (#1551)", () => {
+ it("forwards a well-formed enable envelope to onFleetEnableControl with { ownerMachineId, sessionID }", () => {
+ const seen: Array<{ ownerMachineId: string; sessionID: string }> = [];
+ const host = { ...io(), onFleetEnableControl: (req: { ownerMachineId: string; sessionID: string }) => seen.push(req) };
+ const handled = handleAmicodeBridgeMessage(
+ { source: "amicode", kind: "fleet-enable-control", ownerMachineId: "the-studio", sessionID: "ses_1" },
+ host,
+ );
+ expect(handled).toBe(true);
+ expect(seen).toEqual([{ ownerMachineId: "the-studio", sessionID: "ses_1" }]);
+ });
+
+ it("consumes the envelope even without onFleetEnableControl wired (never foreign-noise)", () => {
+ const host = io();
+ expect(
+ handleAmicodeBridgeMessage(
+ { source: "amicode", kind: "fleet-enable-control", ownerMachineId: "the-studio", sessionID: "ses_1" },
+ host,
+ ),
+ ).toBe(true);
+ });
+
+ it("a malformed payload (missing/non-string ownerMachineId or sessionID) mints nothing — the callback never fires", () => {
+ const seen: unknown[] = [];
+ const host = { ...io(), onFleetEnableControl: (req: unknown) => seen.push(req) };
+ // missing sessionID
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "fleet-enable-control", ownerMachineId: "the-studio" }, host)).toBe(true);
+ // missing ownerMachineId
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "fleet-enable-control", sessionID: "ses_1" }, host)).toBe(true);
+ // non-string ownerMachineId
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "fleet-enable-control", ownerMachineId: 7, sessionID: "ses_1" }, host)).toBe(true);
+ // empty strings
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "fleet-enable-control", ownerMachineId: "", sessionID: "" }, host)).toBe(true);
+ expect(seen).toEqual([]);
+ });
+
+ it("the enable act is NOT on the command lane — the bare command is refused (payload envelope, not a command)", () => {
+ const host = io();
+ // Key Decision: the bare command lane cannot carry the target peer, so the
+ // enable command is deliberately NOT allowlisted.
+ expect(
+ handleAmicodeBridgeMessage(
+ { source: "amicode", kind: "command", command: "amicode.fleet.enableControl" },
+ host,
+ ),
+ ).toBe(false);
+ });
+});
+
// ============================================================================
// dev-tools-update: amicode path validation, tilde expansion (#940). The
// opencode repo-path field is retired (#1115): the update handler validates
diff --git a/packages/extension/test/fleet_control_bootstrap.test.ts b/packages/extension/test/fleet_control_bootstrap.test.ts
index 3577bce1..d2b506dc 100644
--- a/packages/extension/test/fleet_control_bootstrap.test.ts
+++ b/packages/extension/test/fleet_control_bootstrap.test.ts
@@ -21,7 +21,9 @@ import {
evaluateManagementVerified,
establishManagementVerified,
enableSelfOwnedControl,
+ handleEnableControlRequest,
type ControlBootstrapRequest,
+ type EnableControlHandlerDeps,
} from "../src/amicode_service/fleet_control_bootstrap";
import {
findControlGrantByTarget,
@@ -372,3 +374,84 @@ describe("#1545 — shared-peer arm: requires-approval → request→approve han
expect(approveResult.grant.scope).toBe("control");
});
});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// #1551 — handleEnableControlRequest: the LIVE caller decision path
+//
+// The self-owned enable act's extension-side handler — the one the app→extension
+// `fleet-enable-control` envelope drives. It CONFIRMS first (the ADR 0034 D4
+// native modal, injected), then composes establishManagementVerified +
+// enableSelfOwnedControl to mint. Cancelling mints nothing (AC3); a non-self-
+// owned / unverified peer mints nothing (no-privilege-bleed, AC4).
+// ═══════════════════════════════════════════════════════════════════════════
+describe("#1551 handleEnableControlRequest — confirm → mint (self-owned+verified); cancel/shared/unverified → no grant", () => {
+ let grantDeps: LifecycleGrantDeps;
+ let root: string;
+ beforeEach(() => {
+ root = tmproot();
+ grantDeps = {
+ grantStoreFile: join(root, "lifecycle-grants.json"),
+ tokenFactory: () => "ENABLE-TOKEN-1551",
+ now: () => "2026-09-24T00:00:00.000Z",
+ };
+ });
+
+ // A verified, self-owned deps set — the happy path. `confirm` is injected so
+ // the native modal never runs in-test.
+ function verifiedDeps(confirm: (owner: string) => Promise): EnableControlHandlerDeps {
+ return {
+ self: { machineId: SELF_ID, identityKey: SELF_KEY },
+ targetIdentityKey: () => PEER_KEY,
+ ownershipOf: () => "self-owned",
+ getServingPeers: () => [{ machineId: PEER_ID }],
+ readPeerToken: (id) => ({ ok: id === PEER_ID }),
+ resolveAuthority: (t) => (t === PEER_ID ? authorityFor(PEER_ID, SELF_ID) : undefined),
+ confirm,
+ grantDeps,
+ };
+ }
+
+ it("confirm=true on a self-owned, management-verified peer MINTS an active control grant", async () => {
+ let asked = "";
+ const outcome = await handleEnableControlRequest(
+ { ownerMachineId: PEER_ID, sessionID: "ses_abc" },
+ verifiedDeps(async (owner) => {
+ asked = owner;
+ return true;
+ }),
+ );
+ expect(asked).toBe(PEER_ID); // the modal was asked about the target peer
+ expect(outcome.outcome).toBe("minted");
+ const grant = findControlGrantByTarget(PEER_ID, grantDeps);
+ expect(grant).toBeDefined();
+ expect(grant!.scope).toBe("control");
+ expect(grant!.state).toBe("active");
+ expect(grant!.targetMachineId).toBe(PEER_ID);
+ expect(grant!.requesterMachineId).toBe(SELF_ID);
+ });
+
+ it("confirm=false (native modal cancelled) MINTS NOTHING (AC3)", async () => {
+ const outcome = await handleEnableControlRequest(
+ { ownerMachineId: PEER_ID, sessionID: "ses_abc" },
+ verifiedDeps(async () => false),
+ );
+ expect(outcome.outcome).toBe("cancelled");
+ expect(findControlGrantByTarget(PEER_ID, grantDeps)).toBeUndefined();
+ });
+
+ it("no privilege bleed: a SHARED peer mints NOTHING even on confirm (AC4)", async () => {
+ const deps = verifiedDeps(async () => true);
+ deps.ownershipOf = () => "shared";
+ const outcome = await handleEnableControlRequest({ ownerMachineId: PEER_ID, sessionID: "ses_abc" }, deps);
+ expect(outcome.outcome).toBe("not-authorized");
+ expect(findControlGrantByTarget(PEER_ID, grantDeps)).toBeUndefined();
+ });
+
+ it("a self-owned peer that is NOT management-verified (transport down) mints NOTHING", async () => {
+ const deps = verifiedDeps(async () => true);
+ deps.getServingPeers = () => []; // peer not serving → not verified
+ const outcome = await handleEnableControlRequest({ ownerMachineId: PEER_ID, sessionID: "ses_abc" }, deps);
+ expect(outcome.outcome).toBe("not-authorized");
+ expect(findControlGrantByTarget(PEER_ID, grantDeps)).toBeUndefined();
+ });
+});