Skip to content
Open
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
24 changes: 22 additions & 2 deletions docs/cloud-cmux-tui-daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,11 +698,31 @@ nothing else), and `/integrations` (what the machine can use, each with a `help`
command). The shim resolves `cmux vm exec <peer>` through `/peers` when no route file
exists. See docs/vm-identity-edge-auth.md.

## Coding-agent hooks on a machine

Every machine ships the cmux-tui hooks for Claude Code and Codex, installed
for the daemon user (`/home/cmux`): the bake and the create-time install both
run `cmux-tui agent hook install claude codex` right after the binary
(`cmuxTuiInstallCommand`), with the `cmux-tui-hook` helper downloaded from the
same manifest commit as the daemon and placed beside it. A machine whose daemon
is healthy but predates this gets the hooks on attach (`ensureAgentHooks` in
`freestyle.ts`), using the helper of the commit in `/etc/cmux/cmux-tui-pin`;
the daemon keeps running because it already exports `CMUX_TUI_HOOK` into
every pane. The readiness probe (`cmuxTuiHooksReadyCommand`) requires the
installed helper to be byte-equal to the pinned one and the cmux marker in
`~/.claude/settings.json`, `~/.codex/hooks.json`, and the `[hooks]` trust
table in `~/.codex/config.toml`. `agent-config.sh` adds the codex model
provider around that trust table at the first login that sees a boot env, so
the two writers of `config.toml` compose in either order. The bake's
`agent-hooks` step proves all of it on the snapshot.

## Notifications from a machine

`cmux notify` inside a machine is the guest shim (`web/services/vms/guestCli.ts`)
translating to `notification create --title … --body … [--level …] --terminal
$CMUX_TUI_TERMINAL_ID` on the machine's own session. The daemon appends it to
running `cmux-tui --session cloud --quiet notify …` with the arguments untouched
(`--quiet` is dropped when the caller passes `--json` or `--jsonl`); the
daemon's `notify` verb owns the macOS signature (subtitle, scoped `--clear`,
`--reply` refused, `CMUX_TUI_TERMINAL_ID` as the caller terminal). The daemon appends it to
its durable notification ledger and the v2 `session.events` stream carries it
as a delta:

Expand Down
28 changes: 28 additions & 0 deletions web/scripts/build-devbox-freestyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import { VM_GUEST_MODEL_PLANE_ENV_PATH, renderVmGuestModelPlaneEnvFile, vmGuestM
import {
CMUX_TUI_LAYOUT_MARKER_PATH,
CMUX_TUI_SESSION,
CMUX_TUI_HOOK_PROVIDERS,
cmuxTuiHooksReadyCommand,
cmuxTuiInstallCommand,
cmuxTuiPinCheckCommand,
cmuxTuiRunCommand,
Expand Down Expand Up @@ -465,6 +467,32 @@ try {
`${cmuxTuiPinCheckCommand(cmuxTuiSource)} && mkdir -p /etc/cmux && printf '%s %s\n' ${cmuxTuiSource.sha256} ${cmuxTuiSource.commit} > /etc/cmux/cmux-tui-pin && cat /etc/cmux/cmux-tui-pin`,
);

// The install above also wrote the work user's Claude Code and Codex hooks
// (cmux-tui agent hook install), so a Stop, permission request, or question
// in either agent reaches the daemon journal and the owner's Mac as a
// notification with no per-machine setup. Prove the four artifacts and that
// the daemon user's own status verb agrees; then prove the two writers of
// ~/.codex/config.toml compose: hooks first (bake), then the provider block
// agent-config.sh adds at the first login that sees a boot env, with the
// trust state intact and the result still one TOML document.
await step(
"agent-hooks",
[
cmuxTuiHooksReadyCommand(),
`${cmuxTuiRunCommand(`--json agent hook status ${CMUX_TUI_HOOK_PROVIDERS.join(" ")}`)} > /tmp/hook-status.json`,
`node -e 'const r = JSON.parse(require("fs").readFileSync("/tmp/hook-status.json","utf8")); const rows = r.providers || []; const by = Object.fromEntries(rows.map((p) => [p.provider, p])); for (const id of ${JSON.stringify([...CMUX_TUI_HOOK_PROVIDERS])}) { if (!by[id] || by[id].state !== "installed") { console.error(id, by[id]); process.exit(1); } }'`,
`test "$(stat -c %U ${WORK_HOME}/.claude/settings.json ${WORK_HOME}/.codex/hooks.json ${WORK_HOME}/.codex/config.toml | sort -u)" = ${WORK_USER}`,
`! grep -q '^model_provider = ' ${WORK_HOME}/.codex/config.toml`,
`rm -rf /tmp/hook-merge-check && mkdir -p /tmp/hook-merge-check/.codex && cp ${WORK_HOME}/.codex/config.toml /tmp/hook-merge-check/.codex/config.toml`,
`env HOME=/tmp/hook-merge-check OPENAI_BASE_URL=https://example.invalid/v1 OPENAI_API_KEY=cmux-vm-edge-placeholder CMUX_CODEROUTER_URL=https://example.invalid bash -lc 'true'`,
`head -c 200 /tmp/hook-merge-check/.codex/config.toml | grep -q '^model_provider = "cmux"'`,
`grep -q '^\\[hooks' /tmp/hook-merge-check/.codex/config.toml && grep -q '^\\[model_providers.cmux\\]' /tmp/hook-merge-check/.codex/config.toml`,
`python3 -c 'import tomllib,sys; d = tomllib.load(open("/tmp/hook-merge-check/.codex/config.toml","rb")); assert d["model_provider"] == "cmux" and "hooks" in d and d["history"]["persistence"] == "save-all", d'`,
`rm -rf /tmp/hook-merge-check /tmp/hook-status.json`,
"echo agent-hooks-ok",
].join(" && "),
);

// The Ghostty generation panes announce as TERM_PROGRAM_VERSION (the
// supervisor exports it next to TERM_PROGRAM=ghostty; see cmux-devbox-boot).
await step(
Expand Down
7 changes: 7 additions & 0 deletions web/scripts/verify-devbox-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import { agentLaunchCheck } from "./devbox-agent-launch";
import { DEFAULT_VM_EDGE_ALIAS_DOMAIN } from "../services/coderouter/vmGuestEnv";
import path from "node:path";
import {
CMUX_TUI_HOOK_PROVIDERS,
CMUX_TUI_LAYOUT_MARKER_PATH,
CMUX_TUI_SESSION,
cmuxTuiHooksReadyCommand,
cmuxTuiLayoutSelector,
cmuxTuiRunCommand,
resolveCmuxTuiSource,
Expand Down Expand Up @@ -112,6 +114,11 @@ const CHECKS: readonly string[] = [
// Quiet-marks smoke: the bashrc blanks ble.sh's status marks and pins USER
// so no [ble: ...] or "insane environment" text ever renders.
"tmux new-session -d -s marks -x 100 -y 24 && sleep 3 && tmux send-keys -t marks not-a-command Enter && sleep 2 && tmux send-keys -t marks 'printf no-newline' Enter && sleep 2 && out=$(tmux capture-pane -pt marks); tmux kill-session -t marks 2>/dev/null; printf '%s\\n' \"$out\" | grep -E '\\[ble:|ble\\.sh:' && exit 1; echo no-ble-marks",
// Coding-agent hooks: the work user's Claude Code and Codex hooks are
// installed and current (helper byte-equal to the pinned one, cmux marker
// in both provider configs, codex trust table), and the daemon user's own
// status verb reports both providers installed.
`${cmuxTuiHooksReadyCommand()} && ${cmuxTuiRunCommand(`--json agent hook status ${CMUX_TUI_HOOK_PROVIDERS.join(" ")}`)} > /tmp/hook-status.json && node -e 'const r = JSON.parse(require("fs").readFileSync("/tmp/hook-status.json","utf8")); for (const id of ${JSON.stringify([...CMUX_TUI_HOOK_PROVIDERS])}) { const p = (r.providers || []).find((x) => x.provider === id); if (!p || p.state !== "installed") { console.error(id, p); process.exit(1); } }' && rm -f /tmp/hook-status.json && echo agent-hooks-ok`,
// Agent-config generator: a login shell under a throwaway HOME with fake
// model-plane env (placeholder keys, never a token) materializes the codex
// custom provider plus the pi openai-codex override (no route-token
Expand Down
148 changes: 137 additions & 11 deletions web/services/vms/drivers/cmuxTuiDaemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,26 @@ export function cmuxTuiAsDaemonUser(command: string, options?: { readonly exec?:
);
}

export type CmuxTuiSource = { url: string; sha256: string; commit: string; builtAt: string | null };
/**
* One commit's Linux build: the daemon binary and its `cmux-tui-hook` helper,
* both from the same manifest so the hook records a daemon of its own
* generation writes into the journal.
*/
export type CmuxTuiSource = {
url: string;
sha256: string;
commit: string;
builtAt: string | null;
hookUrl: string;
hookSha256: string;
};

export const CMUX_TUI_LINUX_TARGET = "cmux-tui-x86_64-unknown-linux-musl";
export const CMUX_TUI_HOOK_LINUX_TARGET = "cmux-tui-hook-x86_64-unknown-linux-musl";
/** The marker every cmux-owned coding-agent hook entry carries (agent_hook_install.rs COMMAND_MARKER). */
export const CMUX_TUI_HOOK_MARKER = "cmux-tui-journal-hook";
/** Coding agents whose hooks every machine ships with; `cmux-tui agent hook install` names them. */
export const CMUX_TUI_HOOK_PROVIDERS = ["claude", "codex"] as const;
export const CMUX_TUI_DEFAULT_MANIFEST_URL = "https://files.cmux.com/cmux-tui/latest/manifest.json";
const CMUX_TUI_MANIFEST_CACHE_MS = 5 * 60 * 1000;

Expand All @@ -143,27 +160,54 @@ export function parseCmuxTuiManifest(
const record = manifest && typeof manifest === "object" ? manifest as Record<string, unknown> : {};
const commit = typeof record.commit === "string" ? record.commit : "";
const binaries = record.binaries && typeof record.binaries === "object" ? record.binaries as Record<string, unknown> : {};
const sha256 = typeof binaries[CMUX_TUI_LINUX_TARGET] === "string" ? (binaries[CMUX_TUI_LINUX_TARGET] as string).toLowerCase() : "";
const digest = (target: string): string => (typeof binaries[target] === "string" ? (binaries[target] as string).toLowerCase() : "");
const sha256 = digest(CMUX_TUI_LINUX_TARGET);
const hookSha256 = digest(CMUX_TUI_HOOK_LINUX_TARGET);
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new ProviderError(provider, `cmux-tui manifest at ${manifestUrl} has no commit`);
}
if (!/^[0-9a-f]{64}$/.test(sha256)) {
throw new ProviderError(provider, `cmux-tui manifest at ${manifestUrl} has no ${CMUX_TUI_LINUX_TARGET} sha256 — publish artifacts from a main with the musl target`);
}
if (!/^[0-9a-f]{64}$/.test(hookSha256)) {
throw new ProviderError(provider, `cmux-tui manifest at ${manifestUrl} has no ${CMUX_TUI_HOOK_LINUX_TARGET} sha256 — the hook helper ships beside the daemon since cmux-tui-artifacts publishes both`);
}
const base = manifestUrl.replace(/\/manifest\.json$/, "");
return {
url: `${base}/${CMUX_TUI_LINUX_TARGET}`,
sha256,
commit,
builtAt: typeof record.builtAt === "string" ? record.builtAt : null,
hookUrl: `${base}/${CMUX_TUI_HOOK_LINUX_TARGET}`,
hookSha256,
};
}

/** The manifest of one published commit, a sibling of the rolling `latest` pointer. */
export function cmuxTuiPinnedManifestUrl(commit: string, provider: ProviderId = "freestyle"): string {
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new ProviderError(provider, `cmux-tui pin commit ${JSON.stringify(commit)} is not a full sha`);
}
const url = new URL(cmuxTuiManifestUrl(provider));
const segments = url.pathname.split("/");
if (segments.at(-1) !== "manifest.json") {
throw new ProviderError(provider, `cmux-tui manifest URL ${url.href} does not end in /manifest.json`);
}
// `<base>/<pointer>/manifest.json` -> `<base>/<commit>/manifest.json`; a
// root-level `/manifest.json` gains the commit segment. Origin and query survive.
if (segments.length >= 3) segments[segments.length - 2] = commit;
else segments.splice(segments.length - 1, 0, commit);
url.pathname = segments.join("/");
return url.href;
}

let cmuxTuiSourceCache: { url: string; fetchedAt: number; source: CmuxTuiSource } | null = null;

/** The Linux daemon build to install, from the manifest (cached 5 min per manifest URL). */
export async function resolveCmuxTuiSource(provider: ProviderId = "freestyle"): Promise<CmuxTuiSource> {
const manifestUrl = cmuxTuiManifestUrl(provider);
export async function resolveCmuxTuiSource(
provider: ProviderId = "freestyle",
manifestUrl: string = cmuxTuiManifestUrl(provider),
): Promise<CmuxTuiSource> {
if (cmuxTuiSourceCache && cmuxTuiSourceCache.url === manifestUrl && Date.now() - cmuxTuiSourceCache.fetchedAt < CMUX_TUI_MANIFEST_CACHE_MS) {
return cmuxTuiSourceCache.source;
}
Expand Down Expand Up @@ -204,27 +248,109 @@ export function resetCmuxTuiSourceCache(): void {
* Runs as root and installs into the daemon's own home, so a work-user machine
* gets a binary its sessions can execute (/root is 0700) and a legacy machine
* keeps the one it already has.
*
* The same command installs the coding-agent hooks (`cmuxTuiAgentHooksInstallCommand`),
* so a machine from the bake and a machine healed on attach both ship them.
*/
export function cmuxTuiInstallCommand(source: CmuxTuiSource): string {
const bin = '"$CMUX_TUI_BIN"';
const tmp = '"$CMUX_TUI_TMP"';
const pinned = (path: string) => `printf '%s %s\n' ${shellQuote(source.sha256)} ${path} | sha256sum -c >/dev/null 2>&1`;
const fetch =
`if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-delay 2 -o ${tmp} ${shellQuote(source.url)}; ` +
`elif command -v wget >/dev/null 2>&1; then wget -q -O ${tmp} ${shellQuote(source.url)}; ` +
`else false; fi`;
return [
cmuxTuiLayoutSelector(),
`CMUX_TUI_TMP="$CMUX_TUI_BIN.tmp"`,
`mkdir -p "$(dirname "$CMUX_TUI_BIN")"`,
`if [ -x ${bin} ] && ${pinned(bin)}; then :; else ${fetch} && ${pinned(tmp)} && chmod 755 ${tmp} && mv -f ${tmp} ${bin}; fi`,
`if [ -x ${bin} ] && ${pinnedFile(source.sha256, bin)}; then :; else ${fetchTo(tmp, source.url)} && ${pinnedFile(source.sha256, tmp)} && chmod 755 ${tmp} && mv -f ${tmp} ${bin}; fi`,
`ln -sfn ${bin} /usr/local/bin/cmux-tui`,
...hookHelperInstallSteps(source),
// Only the nodes this install created, never the daemon's state tree.
`if [ "$CMUX_TUI_USER" != root ]; then chown "$CMUX_TUI_USER:$CMUX_TUI_USER" "$CMUX_TUI_HOME/.cmux" "$CMUX_TUI_HOME/.cmux/bin" ${bin} 2>/dev/null || true; fi`,
`if [ "$CMUX_TUI_USER" != root ]; then chown "$CMUX_TUI_USER:$CMUX_TUI_USER" "$CMUX_TUI_HOME/.cmux" "$CMUX_TUI_HOME/.cmux/bin" ${bin} ${HOOK_BIN} 2>/dev/null || true; fi`,
`${bin} --version`,
...agentHooksInstallSteps(),
].join(" && ");
}

const HOOK_BIN = '"$CMUX_TUI_HOOK_BIN"';
const HOOK_TMP = '"$CMUX_TUI_HOOK_TMP"';
/** Where `cmux-tui agent hook install` copies the helper for the daemon user (agent_hook_install.rs installed_helper). */
const INSTALLED_HOOK = '"$CMUX_TUI_HOME/.local/share/cmux-tui/bin/cmux-tui-hook"';

function pinnedFile(sha256: string, path: string): string {
return `printf '%s %s\n' ${shellQuote(sha256)} ${path} | sha256sum -c >/dev/null 2>&1`;
}

function fetchTo(path: string, url: string): string {
return (
`if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-delay 2 -o ${path} ${shellQuote(url)}; ` +
`elif command -v wget >/dev/null 2>&1; then wget -q -O ${path} ${shellQuote(url)}; ` +
`else false; fi`
);
}

/**
* The `cmux-tui-hook` helper lands beside the daemon binary: that is the one
* place `cmux-tui agent hook install` looks for it without a PATH search
* (agent_hook_install.rs locate_helper_source). Same pin discipline as the daemon.
*/
function hookHelperInstallSteps(source: CmuxTuiSource): string[] {
return [
`CMUX_TUI_HOOK_BIN="$(dirname "$CMUX_TUI_BIN")/cmux-tui-hook"`,
`CMUX_TUI_HOOK_TMP="$CMUX_TUI_HOOK_BIN.tmp"`,
`if [ -x ${HOOK_BIN} ] && ${pinnedFile(source.hookSha256, HOOK_BIN)}; then :; else ${fetchTo(HOOK_TMP, source.hookUrl)} && ${pinnedFile(source.hookSha256, HOOK_TMP)} && chmod 755 ${HOOK_TMP} && mv -f ${HOOK_TMP} ${HOOK_BIN}; fi`,
];
}

/**
* Writes the Claude Code and Codex hook entries for the daemon user and copies
* the helper into that user's data dir, then proves it: the installed helper
* is byte-equal to the pinned one and every provider config carries the
* cmux marker. Idempotent (the installer rewrites nothing that already matches).
* The daemon exports CMUX_TUI_HOOK into every pane it spawns, so no restart is
* needed for an already running daemon; agents pick the hooks up at their
* next launch.
*/
function agentHooksInstallSteps(): string[] {
return [
cmuxTuiAsDaemonUser(`"$CMUX_TUI_BIN" agent hook install ${CMUX_TUI_HOOK_PROVIDERS.join(" ")} >/dev/null`),
cmuxTuiHooksReadyCheck(),
];
}

/**
* Readiness comes from the installer's own structured status, so a hook entry
* a user edited or reordered (reported `partial`) is repaired instead of
* passing a text grep; plus the helper beside the daemon must be byte-equal
* to the one the daemon user runs.
*/
function cmuxTuiHooksReadyCheck(): string {
const providers = CMUX_TUI_HOOK_PROVIDERS.join(" ");
const installed = JSON.stringify([...CMUX_TUI_HOOK_PROVIDERS]);
return [
`test -x ${INSTALLED_HOOK}`,
`cmp -s ${HOOK_BIN} ${INSTALLED_HOOK}`,
cmuxTuiAsDaemonUser(`"$CMUX_TUI_BIN" --json agent hook status ${providers}`) +
` | python3 -c 'import json, sys; r = json.load(sys.stdin); s = {p["provider"]: p["state"] for p in r.get("providers", [])}; sys.exit(0 if all(s.get(i) == "installed" for i in ${installed}) else 1)'`,
].join(" && ");
}

/**
* Hooks alone, for a machine whose daemon is healthy and pinned but predates
* hook installation: fetch the helper for the daemon's own commit and install
* the provider entries. Never touches the daemon binary or its state.
*/
export function cmuxTuiAgentHooksInstallCommand(source: CmuxTuiSource): string {
return [
cmuxTuiLayoutSelector(),
...hookHelperInstallSteps(source),
`if [ "$CMUX_TUI_USER" != root ]; then chown "$CMUX_TUI_USER:$CMUX_TUI_USER" ${HOOK_BIN} 2>/dev/null || true; fi`,
...agentHooksInstallSteps(),
].join(" && ");
}

/** Exit 0 when the daemon user's coding-agent hooks are installed and current. */
export function cmuxTuiHooksReadyCommand(): string {
return `${cmuxTuiLayoutSelector()} && CMUX_TUI_HOOK_BIN="$(dirname "$CMUX_TUI_BIN")/cmux-tui-hook" && ${cmuxTuiHooksReadyCheck()}`;
}

/** True when the installed binary matches the manifest pin (exit 0 from this command). */
export function cmuxTuiPinCheckCommand(source: CmuxTuiSource): string {
return (
Expand Down
Loading
Loading