diff --git a/CHANGELOG.md b/CHANGELOG.md index 0903fb6..b85ca59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 create workspace symlinks such as Python virtual environments. Symlinks now remain durable inside the isolated Linux computer and are omitted from the symlink-free host mirror instead of failing the entire mirror transaction. +- Repaired Apple resident networking when a VM is still reported running but + its NIC or default route has vanished, without replacing the resident disk. +- Scoped live sidebar status updates to the exact resident identity so Skipper + or guest activity cannot animate idle channel rows. +- Prevented duplicate resident-to-Skipper escalations, unchanged hand-back + loops, and user interviews that merely bounce an agent coordination failure. +- Restored Skipper's durable chief-of-staff and cross-channel coordination + contract, including usable skill metadata, personal scheduling and goal + coordination, scoped history, and durable workflows across domain channels. +- Matched Skipper channel-control tool exposure to the requesting user's exact + authority. A personal-#main owner can create channels; a non-owner member is + no longer offered a tool that execution must reject. +- Made Cowork file contracts durable across follow-ups, queued turns, and + restarts. Docs and Notes require Markdown, Presentations require valid + `.slides.json`, Whiteboards require valid `.whiteboard.json`, and newly + created incompatible command output is rejected and removed without touching + pre-existing user files. + +### Documentation + +- Removed retired sandbox/test-host references from public documentation and + agent-facing product guidance. No test or demo endpoint is represented as a + current product environment. ## [0.0.31] - 2026-08-01 @@ -47,8 +70,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed false current-mobile distribution claims: v0.0.30 has no Android APK, and 1Helm is not currently listed in the public iOS App Store. The current phone/tablet path is the HTTPS browser interface. -- Retired the stale pre-OCI demo claim. `demo.1helm.com` now redirects to the - product site until a new isolated sandbox is deliberately provisioned. ## [0.0.30] - 2026-07-31 diff --git a/desktop/workspace-target.cjs b/desktop/workspace-target.cjs index e71a842..eb792b9 100644 --- a/desktop/workspace-target.cjs +++ b/desktop/workspace-target.cjs @@ -1,7 +1,7 @@ "use strict"; const WORKSPACE_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.1helm\.com$/i; -const RESERVED_WORKSPACES = new Set(["demo.1helm.com", "provision.1helm.com"]); +const RESERVED_WORKSPACE_LABELS = new Set(["demo", "provision"]); const DESKTOP_ACTION_ORIGIN = "https://desktop-action.1helm.invalid"; const CONNECT_PATH = "/connect"; const LOCAL_SETUP_PATH = "/setup"; @@ -20,7 +20,7 @@ function isHostedWorkspaceOrigin(raw) { const origin = normalizeRemoteOrigin(raw); if (!origin) return false; const url = new URL(origin); - return !url.port && WORKSPACE_HOST.test(url.hostname) && !RESERVED_WORKSPACES.has(url.hostname.toLowerCase()); + return !url.port && WORKSPACE_HOST.test(url.hostname) && !RESERVED_WORKSPACE_LABELS.has(url.hostname.toLowerCase().split(".")[0]); } function allowedRemoteUrl(raw, selectedOrigin = "") { diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 351979e..d5802ae 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -15,7 +15,7 @@ npm package name remains `1helm` (lowercase). | Role | Who | Authority | | --- | --- | --- | -| Maintainer | Repository owner (`gitcommit90`) | Merge to `main`, deploy demo VPS, tags/releases, policy | +| Maintainer | Repository owner (`gitcommit90`) | Merge to `main`, maintain the product website, tags/releases, policy | | Agents / automation | Resident tools and CI | Branch, test, open PRs, and report verifiable evidence within granted authority | | Contributors | GitHub contributors | Issues and focused pull requests under the repository policy | diff --git a/docs/VISION.md b/docs/VISION.md index 7f890fc..6111566 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -83,10 +83,6 @@ verification. ## Product truth - `https://1helm.com` is the standalone product and documentation site. -- `https://demo.1helm.com` currently redirects to the product site. The retired - pre-OCI sandbox was removed during the clean-start runtime transition; a - future sandbox must be provisioned explicitly with isolated non-production - state before it is advertised again. - Apple Silicon macOS, Linux systemd, and native Windows x64 + WSL are the synchronized public desktop-host product. Every named desktop release must publish all three from one version and exact source commit. diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index 6ffc4dd..ebf1348 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -37,7 +37,7 @@ Source commit: `` ## Verification - Name the exact automated suites and pass counts. -- Name public demo/site/API checks when applicable. +- Name public product-site/API checks when applicable. - For macOS, state Developer ID signature, Apple notarization, stapling, Gatekeeper, public-download installation on the retained release host, app launch/smoke behavior, and Application Support preservation. diff --git a/public/index.html b/public/index.html index 14b1adb..5eba792 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,6 @@
- + diff --git a/scripts/autonomy-benchmark.mjs b/scripts/autonomy-benchmark.mjs index e1f9dc2..d7d57d9 100644 --- a/scripts/autonomy-benchmark.mjs +++ b/scripts/autonomy-benchmark.mjs @@ -47,7 +47,7 @@ try { const prompt = runtimePromptTiersForChannel(botId, channelId, false, "Install and verify the CLI"); const promptLength = prompt.identity.length + prompt.operating.length + prompt.context.length; - record("compact_capability_map", promptLength < 2_000 && /isolated persistent Linux computer/i.test(prompt.operating) && /skill-arsenal count=/i.test(prompt.context) && !/active-skill-playbooks|### /.test(prompt.context), { + record("compact_capability_map", promptLength < 15_000 && /isolated persistent Linux computer/i.test(prompt.operating) && /skill-arsenal count=/i.test(prompt.context) && !/active-skill-playbooks|### /.test(prompt.context), { characters: promptLength, has_linux_computer: /isolated persistent Linux computer/i.test(prompt.operating), has_skill_inventory: /skill-arsenal count=/i.test(prompt.context), diff --git a/site/content.mjs b/site/content.mjs index c9bb2ad..a9a9876 100644 --- a/site/content.mjs +++ b/site/content.mjs @@ -32,7 +32,7 @@ const connections = doc("/manual/connections", "Connections", "How Gmail, Photon const installMac = doc("/manual/install-macos", "Install on macOS", "Install the signed, notarized Apple Silicon 1Helm app and initialize per-channel Linux computers.", `

The native consumer product currently targets Apple Silicon Macs.

Requirements

Install

  1. Download the current DMG.
  2. Open it and drag 1Helm to Applications.
  3. Open 1Helm. Gatekeeper verifies the Developer ID signature and notarization ticket.
  4. Complete Captain → Providers → Workspace. Approve Apple's signed runtime inline if requested.

Data and upgrades

This generation stores application state under ~/Library/Application Support/1Helm-OCI-v1. Profile → Check for updates asks the Mac hosting 1Helm to download and verify the signed, notarized update. When the host reports it ready, Restart & install quiesces the local service and replaces the app. The browser is never given a DMG as the update action, and Application Support remains in place.

Removal

Use Settings → Admin → Prepare to remove 1Helm before trashing the app. This removes only verified 1Helm-owned channel machines while preserving the application state for a future reinstall.

${button("/download/macos", "Download current DMG", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with one OCI container per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own durable Podman container.

Supported baseline

Ubuntu or Debian with systemd and apt, cgroup v2, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit Podman and delegated cgroups.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, installs Podman and the fixed root-owned OCI helper, creates a restricted 1helm service account, stores control-plane and runtime state in /var/lib/1helm-oci-v1, and atomically switches /opt/1helm/current.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires GitHub's SHA-256 asset digest, applies the fixed application and OCI contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm-oci-v1 as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable recovery state.

`); const installWsl = doc("/manual/install-windows", "Install on Windows + WSL", "Native Windows hosting with one shared WSL 2 runtime and one OCI container per resident.", `

Windows 11 hosts one installation-scoped WSL 2 runtime. Every ordinary channel owns a distinct durable OCI container inside it.

Implemented host contract

The resident runs as UID/GID 1000 in /workspace. App state lives under %APPDATA%\\1Helm-OCI-v1, and the shared runtime disk lives under %LOCALAPPDATA%\\1Helm-Runtime.

Install

  1. Download the current Setup executable.
  2. Run the installer on Windows 11 x64.
  3. Open 1Helm and complete Captain → Providers → Workspace. Approve Microsoft’s pinned WSL 2 host setup once if requested.

Updates arrive through the app’s native update feed on the machine hosting 1Helm. The release notes record the installer’s Authenticode status.

${button("/download/windows", "Download for Windows", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); -const selfHosting = doc("/manual/self-hosting", "Self-hosting", "Ports, state, backups, HTTPS, upgrades, health checks, and platform boundaries for self-hosted 1Helm.", `

1Helm is the server. The public 1helm.com website is not a dependency of your installed workspace. The retired pre-OCI demo sandbox is no longer running; demo.1helm.com redirects here until a new isolated sandbox is deliberately provisioned.

Ports

The source runtime defaults to 8123. Native desktop apps choose an ephemeral loopback port. The standalone product website uses 8130. These are separate processes and data trees.

State

Set CTRL_DATA_DIR to a persistent, restricted directory. Never place it in a public web root. Back it up only while the service is stopped or with a filesystem/database-consistent snapshot.

HTTPS

Use Settings → Domains for a workspace-managed Cloudflare tunnel, or put a conventional HTTPS reverse proxy in front of a headless host. Preserve WebSocket upgrades and do not strip Authorization headers.

Health

${code("health", "curl -fsS http://127.0.0.1:8123/api/setup/status\nsystemctl is-active 1helm\njournalctl -u 1helm --since '15 minutes ago'", "bash")}

Upgrades

Use a unique released version. Stop the service, take a state backup, install the tagged source, run npm ci and npm run build, then restart and verify health. Database migrations are additive, but rollback still requires the pre-upgrade data backup.

Resource guidance

A minimal control plane can run in 4 GiB RAM; 8 GiB is a more practical baseline. Model inference usually remains at connected providers, but browser automation, builds, media processing, and several concurrent residents increase CPU, RAM, and storage demand.

`); +const selfHosting = doc("/manual/self-hosting", "Self-hosting", "Ports, state, backups, HTTPS, upgrades, health checks, and platform boundaries for self-hosted 1Helm.", `

1Helm is the server. The public 1helm.com website is documentation and release distribution, not a dependency of your installed workspace.

Ports

The source runtime defaults to 8123. Native desktop apps choose an ephemeral loopback port. The standalone product website uses 8130. These are separate processes and data trees.

State

Set CTRL_DATA_DIR to a persistent, restricted directory. Never place it in a public web root. Back it up only while the service is stopped or with a filesystem/database-consistent snapshot.

HTTPS

Use Settings → Domains for a workspace-managed Cloudflare tunnel, or put a conventional HTTPS reverse proxy in front of a headless host. Preserve WebSocket upgrades and do not strip Authorization headers.

Health

${code("health", "curl -fsS http://127.0.0.1:8123/api/setup/status\nsystemctl is-active 1helm\njournalctl -u 1helm --since '15 minutes ago'", "bash")}

Upgrades

Use a unique released version. Stop the service, take a state backup, install the tagged source, run npm ci and npm run build, then restart and verify health. Database migrations are additive, but rollback still requires the pre-upgrade data backup.

Resource guidance

A minimal control plane can run in 4 GiB RAM; 8 GiB is a more practical baseline. Model inference usually remains at connected providers, but browser automation, builds, media processing, and several concurrent residents increase CPU, RAM, and storage demand.

`); export const pages = { "/manual/getting-started": gettingStarted, "/manual/architecture": architecture, "/manual/outcome-ownership": outcomeOwnership, diff --git a/site/manual.html b/site/manual.html index f9a1742..72e2942 100644 --- a/site/manual.html +++ b/site/manual.html @@ -260,11 +260,6 @@

FAQ

Your data root (~/Library/Application Support/1Helm-OCI-v1 on macOS, /var/lib/1helm-oci-v1 on Linux, or %APPDATA%\1Helm-OCI-v1 plus %LOCALAPPDATA%\1Helm-Runtime on Windows) holds the control plane and resident worlds. Back it up as one coherent installation, reinstall 1Helm, and your crew comes back. Never delete those paths during a move.

-
- Is demo.1helm.com the product? -

No. The retired pre-OCI sandbox is no longer running, and demo.1helm.com now redirects to the product site. A future demo must be provisioned explicitly with isolated non-production state. The real product is the downloadable, self-hosted 1Helm runtime, and your installation's state stays on your machine.

-
-
Why a crew instead of one big agent?

Isolation, specialization, and continuity. Your finances shouldn't share a filesystem with your travel plans, and an agent that owns one job gets genuinely good at that job. Each crew member has its own computer, memory, and skills.

diff --git a/site/server.mjs b/site/server.mjs index f32f664..5f73257 100644 --- a/site/server.mjs +++ b/site/server.mjs @@ -326,11 +326,6 @@ function serveFile(req, res, file, cache = "public, max-age=86400") { const server = createServer(async (req, res) => { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : "/"; - const hostname = String(req.headers.host || "").split(":")[0].toLowerCase(); - if (hostname === "demo.1helm.com") { - redirect(res, `${ORIGIN}${path === "/" ? "" : path}${url.search}`, 301); - return; - } if (path === "/api/feedback" && req.method === "POST") { if (feedbackRateLimited(req)) { answer(res, 429, JSON.stringify({ error: "Too many feedback reports. Try again shortly." }), { diff --git a/src/client/app.ts b/src/client/app.ts index 532c051..70b6a9d 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,4 +1,4 @@ -import { api, downloadAuthenticatedFile, initializeApiTransport, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel } from "./api.ts"; +import { api, downloadAuthenticatedFile, initializeApiTransport, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel, type ResidentAgent } from "./api.ts"; import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts"; import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts"; import { disableNativeNotifications, hydrateNotificationPreferences, playNotification, restoreNativeNotifications, setNativeNotificationNavigation } from "./notifications.ts"; @@ -590,8 +590,7 @@ function onEvent(e: any): void { S.view = "chat"; if (S.channelId) void openChannel(S.channelId); else renderApp(); } else renderSidebar(); } else if (e.type === "agent_status") { - const channel = S.channels.find((item) => item.id === e.channelId); - if (channel?.agent) channel.agent.status = e.status; + applyAgentStatusEvent(e); // Sidebar shows bouncing working dots on every channel row — always refresh. renderSidebar(); if (e.channelId === S.channelId) renderHeader(); @@ -668,6 +667,16 @@ function onEvent(e: any): void { } } +/** Apply live status only when the event belongs to the resident rendered for + * that channel. Skipper and invited agents can work inside an ordinary + * channel, but their status must never repaint the channel resident. */ +export function applyAgentStatusEvent(e: { channelId: number; agentId: number; status: ResidentAgent["status"] }): boolean { + const channel = S.channels.find((item) => Number(item.id) === Number(e.channelId)); + if (!channel?.agent || Number(channel.agent.id) !== Number(e.agentId)) return false; + channel.agent.status = e.status; + return true; +} + function applyMessage(msg: Message, isUpdate: boolean, authoritativeParent?: Message): void { const list = msg.parent_id == null ? S.messages : (S.threadRoot && msg.parent_id === S.threadRoot.id ? S.threadReplies : null); const i = list?.findIndex((m) => m.id === msg.id) ?? -1; diff --git a/src/server/bots.ts b/src/server/bots.ts index 664f17b..6914fe2 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -49,6 +49,7 @@ import { inspectWebSource } from "./web-source.ts"; import { fetchPublicWebImage } from "./web-source.ts"; import { searchWeb } from "./web-search.ts"; import { readChannelThread, searchChannelHistory } from "./history.ts"; +import { coworkContextFromRootBody, coworkFormatContract, enforceCoworkCommandOutput, snapshotCoworkSurface } from "./cowork-contract.ts"; type ChatMsg = { role: string; content: string; tool_calls?: ToolCall[]; tool_call_id?: string; name?: string }; type ToolCall = { id: string; type: "function"; function: { name: string; arguments: string } }; @@ -185,23 +186,39 @@ export type RuntimePromptTiers = { identity: string; operating: string; context: /** Keep stable identity and factual capabilities separate from volatile turn * context. This also gives provider prefix caches a stable compact prefix. */ -function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: number, hostAuthorized: boolean, task = ""): RuntimePromptTiers { +function skipperControlAuthorized(channelId: number, requestUserId: number, hostAuthorized: boolean): boolean { + return hostAuthorized || Boolean(requestUserId && q1("SELECT 1 FROM channels WHERE id=? AND personal_main_owner_id=?", channelId, requestUserId)); +} + +function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: number, hostAuthorized: boolean, task = "", requestUserId = 0): RuntimePromptTiers { const channel = q1("SELECT name, purpose FROM channels WHERE id=?", channelId); if (agent?.kind === "skipper") { const resident = q1(`SELECT a.name, a.display_name, p.purpose FROM agents a JOIN agent_channels ac ON ac.agent_id=a.id LEFT JOIN agent_profiles p ON p.agent_id=a.id WHERE ac.channel_id=?`, channelId); const identity = [ - `You are @${bot.name}, this 1Helm workspace's Skipper.`, + `You are @${bot.name}, the single workspace-wide Skipper, chief of staff, and root operator for this 1Helm workspace.`, + String(agent.instructions || bot.prompt || ""), + "You are the Captain's durable cross-channel operating partner. You retain workspace-wide memory, history, control scope, and the full shared skill arsenal. Ordinary channel residents are intentionally siloed specialists; never claim that a resident has broader cross-channel awareness or persistence than you.", + "Stay calm, candid, resourceful, and accountable for closure. Preserve the user's priorities and voice without imitating transient frustration or another agent's persona.", ].filter(Boolean).join("\n\n"); const operating = [ + "Own personal and operational coordination as well as infrastructure: schedules, reminders, tasks, goals, projects, calendars, relationships, home, health, and finances may live in focused domain channels, while you coordinate the unified cross-channel view and route focused work to those specialists. Never dismiss a scheduling or assistant request as outside Skipper's role.", + "Bias toward safe, reversible action. Inspect authoritative state, act with the native tools already available, verify the observable outcome, and report it. Ask only at a real human boundary; never substitute interviews, narration, permission-seeking, or a future promise for work you can perform now.", + "Treat tool results as evidence: retry transient failures with a bounded changed strategy, stop repeating an unchanged failure, and preserve one useful evidenced blocker. Never fabricate success or erase a prior useful answer.", + "You oversee and unblock. Residents normally use their own Linux computer, internet access, tools, workspace, and memory directly. Skipper becomes involved only for a true host, credential, fleet, cross-channel, or missing-capability boundary—not for routine resident shell, SSH, downloads, or web work.", isMainChannel(channelId) - ? "#main is the Captain's private authority channel. It has no resident agent. Your assigned Skipper computers and the tools listed for this turn remain available here." - : "This ordinary channel has one resident agent. You are present only for the invoking thread; call_agent can return work to that resident.", + ? "#main is the user's private Skipper channel. Own requests there directly through completion. It has no resident by design; your durable memory, shared skills, control-plane tools, and assigned Skipper computers remain available." + : "This ordinary channel has one siloed resident specialist. Perform only the boundary-crossing work requested, then call_agent with concrete evidence so that resident finishes the original outcome.", "You already own automatic pressure-aware channel-computer lifecycle care: periodic fleet reconciliation, safe CPU/RAM resizing, health/update/repair, obligation-aware sleep, and wakeups for due work. Fleet metadata reports live guest load, available memory, and disk-used percentage when known. The mirror quota is only the guest-to-host copy safety limit, never VM storage capacity; actual guest capacity is unknown when 1Helm cannot prove it.", + "Never claim that you inspected, ran, created, scheduled, or verified something unless the matching tool completed. Use Markdown, keep answers focused, and attach user-facing artifacts rather than only quoting a path.", "The callable tools below are your current capabilities. Their implementations enforce authority and isolation boundaries.", ].join("\n\n"); const context = [ `${resident ? `Resident: @${resident.name} — ${resident.purpose || "no recorded purpose"}.` : "No resident agent."}`, + "The complete invoking thread is provided below. Do not ask the user to repeat it.", + skipperControlAuthorized(channelId, requestUserId, hostAuthorized) + ? "This user may use Skipper's scoped native channel controls here. Act directly when requested." + : "This user is not authorized for scoped channel-control mutations in this channel. Do not imply the capability is missing; explain the authority boundary if asked.", agent?.id ? agentSkillContext(Number(agent.id), task) : "", ].filter(Boolean).join("\n\n"); return { identity, operating, context }; @@ -212,12 +229,19 @@ function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: visiting ? `You are @${bot.name}, a temporary expert invited into this one thread in #${channel?.name || "channel"}. You remain resident in your own channel. Do not treat this channel, its memory, or its workspace as part of your permanent world.` : `You are @${bot.name}, the one resident agent for #${channel?.name || "channel"} inside 1Helm.`, + String(agent?.instructions || bot.prompt || ""), + visiting ? "" : "You are this channel's durable operating partner, not a generic chatbot. Be calm, candid, resourceful, and accountable for closure while learning durable preferences without mimicking momentary emotion.", ].filter(Boolean).join("\n\n"); const operating = [ visiting - ? "You are a temporary thread guest. No shell, workspace, or durable-memory capability is attached to this invitation." - : "You own an isolated persistent Linux computer for this channel. Its durable workspace is /workspace. The run_command tool executes there; files, memory, and other listed tools belong to this channel.", + ? "Contribute only the expertise requested in this thread. No shell, workspace, or durable-memory capability is attached to this invitation." + : "You own an isolated persistent Linux computer for this channel. Its durable workspace is /workspace. You have direct shell, internet, SSH, package-install, file, memory, and listed-tool autonomy inside it. Routine commands, downloads, browsing, installations, and network access do not require Skipper or user approval.", + visiting ? "" : "Own the requested outcome. Inspect and act instead of returning tutorials, interviews, rationalizations, or plans for work you can perform. If you make the wrong artifact or implementation choice, acknowledge it briefly and fix it; do not defend the mistake.", visiting ? "" : "Workspace file contracts: Markdown for notes and documents; plain text source files for code. /workspace/whiteboards holds `.whiteboard.json` Excalidraw scenes and /workspace/presentations holds `.slides.json` decks — these two folders are rendered by exact schemas, so never place .html decks or invented JSON formats there. When a request supplies a format contract for a folder, follow it exactly.", + "Treat tool results as evidence. Retry transient failures with a bounded changed strategy, stop repeating unchanged failures, and verify before claiming success.", + "If work truly crosses the resident boundary—host/native state, credentials, another channel, fleet lifecycle, or a missing capability—call Skipper once with the exact operation and evidence. Do not escalate routine shell, SSH, internet, download, or resident-computer work.", + visiting ? "" : "Use ask_user only for consequential human judgment, missing credentials the human must supply, external authority, or an irreversible commitment. Difficulty and harmless implementation choices are not blockers.", + "Use Markdown. Keep answers focused and attach user-facing artifacts rather than only naming a path.", "The callable tools below are your current capabilities. Their implementations enforce authority and isolation boundaries.", ].filter(Boolean).join("\n\n"); const context = [ @@ -227,13 +251,13 @@ function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: return { identity, operating, context }; } -export function runtimePromptTiersForChannel(botId: number, channelId: number, hostAuthorized = false, task = ""): RuntimePromptTiers { +export function runtimePromptTiersForChannel(botId: number, channelId: number, hostAuthorized = false, task = "", requestUserId = 0): RuntimePromptTiers { const bot = q1("SELECT * FROM bots WHERE id=?", botId); if (!bot) throw new Error("Bot not found."); - return systemPromptTiers(bot, agentForBot(botId) as RuntimeAgent | undefined, channelId, hostAuthorized, task); + return systemPromptTiers(bot, agentForBot(botId) as RuntimeAgent | undefined, channelId, hostAuthorized, task, requestUserId); } -function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boolean, channelId: number): unknown[] | undefined { +function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boolean, channelId: number, requestUserId: number): unknown[] | undefined { const computers = q("SELECT computer_id FROM bot_computers WHERE bot_id=?", bot.id); const tools: unknown[] = []; const skipper = agent?.kind === "skipper"; @@ -283,7 +307,7 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo }, }); } - if (skipper && (hostAuthorized || Boolean(q1("SELECT 1 FROM channels WHERE id=? AND personal_main_owner_id IS NOT NULL", channelId)))) { + if (skipper && skipperControlAuthorized(channelId, requestUserId, hostAuthorized)) { tools.push({ type: "function", function: { @@ -363,6 +387,16 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo }, }, }); + tools.push({ + type: "function", + function: { + name: "schedule_workflow", + description: "Schedule durable recurring work in one scoped resident channel. Use this from #main to coordinate reminders and repeated work across domain channels.", + parameters: { type: "object", properties: { channel: { type: "string", description: "Target resident channel name or id." }, name: { type: "string" }, prompt: { type: "string", description: "Self-contained outcome and verification contract for every run." }, interval_seconds: { type: "integer", minimum: 60, maximum: 31536000 }, start_in_seconds: { type: "integer", minimum: 1 }, max_runs: { type: "integer", minimum: 0, maximum: 100000 } }, required: ["channel", "name", "prompt", "interval_seconds"] }, + }, + }); + tools.push({ type: "function", function: { name: "list_workflows", description: "List durable recurring work across the user's scoped channels, or one named channel.", parameters: { type: "object", properties: { channel: { type: "string" } } } } }); + tools.push({ type: "function", function: { name: "set_workflow_status", description: "Pause, resume, or complete a durable workflow in one scoped resident channel.", parameters: { type: "object", properties: { channel: { type: "string" }, workflow_id: { type: "integer" }, status: { type: "string", enum: ["active", "paused", "complete"] } }, required: ["channel", "workflow_id", "status"] } } }); } if (skipper && hostAuthorized) { tools.push({ type: "function", function: { name: "run_thread_audit", description: "Run the authoritative workspace thread-status audit now and report how many threads were examined and changed.", parameters: { type: "object", properties: {} } } }); @@ -411,8 +445,9 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo if (!visiting) { tools.push({ type: "function", function: { name: "search_channel_history", - description: "Search this channel's raw prior-session transcripts semantically, by exact text/date, or list recent messages. Returns scoped message and session references.", + description: skipper ? "Search raw prior-session transcripts in the current or one scoped channel. Skipper uses this for workspace-wide coordination without pretending residents share memory." : "Search this channel's raw prior-session transcripts semantically, by exact text/date, or list recent messages. Returns scoped message and session references.", parameters: { type: "object", properties: { + ...(skipper ? { channel: { type: "string", description: "Optional scoped channel name or id; defaults to the current channel." } } : {}), query: { type: "string", description: "Concept or text to find. Omit to list recent messages." }, mode: { type: "string", enum: ["semantic", "exact"], default: "semantic" }, from: { type: "string", description: "Optional ISO date/time lower bound." }, @@ -422,8 +457,8 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo } }); tools.push({ type: "function", function: { name: "read_channel_session", - description: "Read the complete raw transcript for one session returned by search_channel_history.", - parameters: { type: "object", properties: { thread_root_id: { type: "integer" } }, required: ["thread_root_id"] }, + description: skipper ? "Read a complete raw transcript from the current or one scoped channel." : "Read the complete raw transcript for one session returned by search_channel_history.", + parameters: { type: "object", properties: { ...(skipper ? { channel: { type: "string", description: "Optional scoped channel name or id; defaults to the current channel." } } : {}), thread_root_id: { type: "integer" } }, required: ["thread_root_id"] }, } }); tools.push({ type: "function", function: { name: "list_skills", @@ -696,17 +731,17 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo /** Narrow diagnostic surface used by integration coverage to prove the exact * production tool set without duplicating its capability rules. */ -export function runtimeToolNamesForChannel(botId: number, channelId: number, hostAuthorized = false): string[] { - return runtimeToolDefinitionsForChannel(botId, channelId, hostAuthorized).map((tool) => tool.name); +export function runtimeToolNamesForChannel(botId: number, channelId: number, hostAuthorized = false, requestUserId = 0): string[] { + return runtimeToolDefinitionsForChannel(botId, channelId, hostAuthorized, requestUserId).map((tool) => tool.name); } /** Narrow diagnostic surface for verifying capability descriptions that carry * operationally important safety/automation semantics. */ -export function runtimeToolDefinitionsForChannel(botId: number, channelId: number, hostAuthorized = false): { name: string; description: string }[] { +export function runtimeToolDefinitionsForChannel(botId: number, channelId: number, hostAuthorized = false, requestUserId = 0): { name: string; description: string }[] { const bot = q1("SELECT * FROM bots WHERE id=?", botId); if (!bot) return []; const agent = agentForBot(botId) as RuntimeAgent | undefined; - return (toolsFor(bot, agent, hostAuthorized, channelId) || []).map((tool) => + return (toolsFor(bot, agent, hostAuthorized, channelId, requestUserId) || []).map((tool) => ({ name: String((tool as { function?: { name?: string } }).function?.name || ""), description: String((tool as { function?: { description?: string } }).function?.description || ""), @@ -749,15 +784,19 @@ export async function generateAndAttachImage( return attachWorkspaceFileToMessage(channelId, messageId, threadId, relativePath, actor, fileName); } -export function buildContext(bot: Row, agent: RuntimeAgent | undefined, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, hostAuthorized: boolean, hiddenContext?: string): ChatMsg[] { +export function buildContext(bot: Row, agent: RuntimeAgent | undefined, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, hostAuthorized: boolean, hiddenContext?: string, requestUserId = 0): ChatMsg[] { const currentTask = String(q1("SELECT body FROM messages WHERE id=?", triggerId)?.body || ""); - const prompt = systemPromptTiers(bot, agent, channelId, hostAuthorized, currentTask); + const prompt = systemPromptTiers(bot, agent, channelId, hostAuthorized, currentTask, requestUserId); const messages: ChatMsg[] = [ { role: "system", content: `\n${prompt.identity}\n` }, { role: "system", content: `\n${prompt.operating}\n` }, ]; if (prompt.context) messages.push({ role: "system", content: `\n${prompt.context}\n` }); - if (hiddenContext) messages.push({ role: "system", content: `\n${hiddenContext}\n` }); + const rootBody = String(q1("SELECT body FROM messages WHERE id=?", threadRootId)?.body || ""); + const cowork = coworkContextFromRootBody(rootBody); + const durableCoworkContract = cowork ? coworkFormatContract(cowork.path, cowork.kind === "folder") : ""; + const activeCoworkContract = durableCoworkContract || hiddenContext || ""; + if (activeCoworkContract) messages.push({ role: "system", content: `\n${activeCoworkContract}\n` }); const threadId = threadIdForRoot(threadRootId, channelId) ?? ensureThread(threadRootId, channelId); const thread = q1("SELECT status, summary FROM threads WHERE id=?", threadId); const memories = relevantMemory(channelId, threadId).filter((memory) => Number(memory.thread_id || 0) !== threadId || String(memory.kind) !== "summary"); @@ -1072,6 +1111,21 @@ async function executeSkipperControlTool(name: string, args: Record ({ channel: `#${channel.name}`, obligations: computerObligations(Number(channel.id)), workflows: listWorkflows(Number(channel.id)) })) }); } + if (name === "schedule_workflow") { + const channel = scopedChannel(args.channel, userId, hostAuthorized); + const workflow = createWorkflow({ channelId: Number(channel.id), name: String(args.name || ""), prompt: String(args.prompt || ""), intervalSeconds: Number(args.interval_seconds), startInSeconds: args.start_in_seconds == null ? undefined : Number(args.start_in_seconds), maxRuns: Number(args.max_runs || 0) }); + return `Scheduled durable recurring workflow #${workflow.id} (${workflow.name}) in #${channel.name}; next_run=${workflow.next_run}.`; + } + if (name === "list_workflows") { + const channels = args.channel ? [scopedChannel(args.channel, userId, hostAuthorized)] : scopedChannelRows(userId, hostAuthorized, true); + return JSON.stringify({ channels: channels.map((channel) => ({ channel: `#${channel.name}`, workflows: listWorkflows(Number(channel.id)) })) }); + } + if (name === "set_workflow_status") { + const channel = scopedChannel(args.channel, userId, hostAuthorized); + const status = String(args.status || "") as "active" | "paused" | "complete"; + if (!["active", "paused", "complete"].includes(status)) throw new Error("Workflow status must be active, paused, or complete."); + return JSON.stringify(setWorkflowStatus(Number(args.workflow_id), Number(channel.id), status)); + } if (name === "run_thread_audit") return JSON.stringify(await runThreadAuditPass()); if (name === "run_agent_review") { if (args.channel) { @@ -1156,6 +1210,8 @@ function callSkipper(agent: RuntimeAgent, channelId: number, threadRootId: numbe const skipper = q1("SELECT b.* FROM bots b JOIN agents a ON a.bot_id=b.id WHERE a.kind='skipper' AND a.status<>'deleted' LIMIT 1"); if (!skipper) return "Skipper is not configured yet."; const threadId = threadIdForRoot(threadRootId, channelId) ?? ensureThread(threadRootId, channelId); + const pending = q1("SELECT id FROM escalations WHERE thread_id=? AND from_agent_id=? AND status='open' ORDER BY id DESC LIMIT 1", threadId, agent.id); + if (pending) return `Error: Skipper escalation ${pending.id} is already open for this thread. Do not dispatch the same boundary work again; finish from its result or report one bounded blocker.`; const escalationId = run( "INSERT INTO escalations (thread_id, channel_id, from_agent_id, reason, status, created) VALUES (?,?,?,?,'open',?)", threadId, channelId, agent.id, reason.slice(0, 4000), now(), @@ -1512,8 +1568,8 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread emitNow(); if (!preparedMessageId) broadcastToChannel(channelId, { type: "message", message: serializeMessage(msgId), parent: serializeMessage(threadRootId) }); - const messages = buildContext(bot, agent, channelId, triggerId, threadRootId, fresh, hostAuthorized, hiddenContext); - const tools = toolsFor(bot, agent, hostAuthorized, channelId); + const messages = buildContext(bot, agent, channelId, triggerId, threadRootId, fresh, hostAuthorized, hiddenContext, requestUserId); + const tools = toolsFor(bot, agent, hostAuthorized, channelId, requestUserId); const actor = agent?.kind === "skipper" ? "skipper" : "agent"; try { for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) { @@ -1612,9 +1668,19 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread if ((exactToolFailures.get(failureSignature) || 0) >= 1) { result = "Error: this unchanged tool call already failed. It was not repeated; change strategy or explain the evidenced blocker."; } else if (name === "run_command") { + const cowork = agent?.kind === "channel" + ? coworkContextFromRootBody(String(q1("SELECT body FROM messages WHERE id=?", threadRootId)?.body || "")) + : null; + const coworkBefore = cowork ? snapshotCoworkSurface(channelId, cowork) : null; result = await runCommand(bot, agent, channelId, input, Number(args.computer_id) || 0, turnSignal); requireActiveTurn(channelId, controller.signal); - if (agent?.kind === "channel") syncWorkspaceArtifacts(channelId, threadId, "agent"); + if (agent?.kind === "channel") { + syncWorkspaceArtifacts(channelId, threadId, "agent"); + if (cowork && coworkBefore) { + const contractError = enforceCoworkCommandOutput(channelId, threadId, cowork, coworkBefore); + if (contractError) result = contractError; + } + } } else if (name === "search_web" && !visiting) { const searched = await searchWeb(String(args.query || ""), String(args.category || "web"), Number(args.limit) || 10, turnSignal); for (const item of searched.results) if (item.image_url) searchedWebImages.set(item.image_url, { sourceUrl: item.url, title: item.title }); @@ -1666,9 +1732,13 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread } else if (name === "read_skill" && agent?.id && !visiting) { result = JSON.stringify(readAgentSkill(Number(agent.id), String(args.slug || ""))); } else if (name === "search_channel_history" && agent?.id && !visiting) { - result = JSON.stringify(searchChannelHistory(agent, channelId, args)); + const historyChannelId = agent.kind === "skipper" && args.channel && skipperControlAuthorized(channelId, requestUserId, hostAuthorized) + ? Number(scopedChannel(args.channel, requestUserId, hostAuthorized).id) : channelId; + result = JSON.stringify(searchChannelHistory(agent, historyChannelId, args)); } else if (name === "read_channel_session" && agent?.id && !visiting) { - result = JSON.stringify(readChannelThread(agent, channelId, args.thread_root_id)); + const historyChannelId = agent.kind === "skipper" && args.channel && skipperControlAuthorized(channelId, requestUserId, hostAuthorized) + ? Number(scopedChannel(args.channel, requestUserId, hostAuthorized).id) : channelId; + result = JSON.stringify(readChannelThread(agent, historyChannelId, args.thread_root_id)); } else if (name === "remember") { const memoryId = recordMemory({ channelId, threadId, kind: String(args.kind || "fact"), content: input, sourceMessageId: msgId, authorType: actor }); result = `Recorded channel memory ${memoryId}.`; @@ -1696,7 +1766,11 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread const interveningAction = priorQuestion?.answered ? q1("SELECT 1 FROM tool_actions WHERE thread_id=? AND created>? AND status='complete' AND tool<>'ask_user' LIMIT 1", threadId, priorQuestion.answered) : undefined; const nativeSetupAvailable = /\b(?:connect|set\s*up|authorize)\b[\s\S]{0,80}\bgmail\b|\bgmail\b[\s\S]{0,80}\b(?:connect|set\s*up|authorize)\b/i.test(outcomeRequest); const askUserValidation = validateAskUserInput(args); - if (nativeSetupAvailable) { + const residentEscalation = escalationId && agent?.kind === "skipper" + ? Boolean(q1("SELECT 1 FROM escalations WHERE id=? AND from_agent_id IS NOT NULL", escalationId)) : false; + if (residentEscalation) { + result = "Error: Skipper may not turn an agent coordination failure into a routing interview for the user. Execute the true host boundary if authorized, or return one bounded blocker."; + } else if (nativeSetupAvailable) { result = "Error: Gmail setup has a native connect_gmail capability. Use it directly; OAuth authorization is a connector action, not an interview."; } else if (priorQuestion?.answered && !interveningAction) { result = "Error: a consecutive interview round is not allowed without intervening action or new evidence. Continue from the existing answer and act."; @@ -1736,7 +1810,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread const status = String(args.status || "") as "active" | "paused" | "complete"; if (!["active", "paused", "complete"].includes(status)) result = "Error: workflow status must be active, paused, or complete."; else result = JSON.stringify(setWorkflowStatus(Number(args.workflow_id), channelId, status)); - } else if (["list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations"].includes(name) + } else if (["list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations", "schedule_workflow", "list_workflows", "set_workflow_status"].includes(name) && agent?.kind === "skipper" && (hostAuthorized || Boolean(q1("SELECT 1 FROM channels WHERE id=? AND personal_main_owner_id=?", channelId, requestUserId)))) { result = String(await executeSkipperControlTool(name, args, requestUserId, hostAuthorized)); } else if (["run_thread_audit", "run_agent_review"].includes(name) && agent?.kind === "skipper" && hostAuthorized) { @@ -1752,8 +1826,12 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread ? "Error: resident agents cannot enter #main. Use Skipper's own tools directly." : inviteAgent(agent, channelId, threadId, threadRootId, String(args.agent || ""), String(args.reason || "")); } else if (name === "call_agent" && agent?.kind === "skipper") { + const residentEscalation = escalationId ? Boolean(q1("SELECT 1 FROM escalations WHERE id=? AND from_agent_id IS NOT NULL", escalationId)) : false; + const boundaryEvidence = lastCompletedTool && !["call_agent", "ask_user"].includes(lastCompletedTool.name); result = isMainChannel(channelId) ? "Error: resident agents cannot be called or invited into #main. Use Skipper's own tools directly." + : residentEscalation && !boundaryEvidence + ? "Error: Skipper cannot hand an unchanged blocker back to the resident. Complete a real boundary action first, or report one bounded failure without re-invoking the resident." : callAgent(agent, channelId, threadId, threadRootId, String(args.agent || ""), String(args.reason || ""), hostAuthorized); if (!result.startsWith("Error:")) handedBack = true; } else if (name === "create_skill" && agent?.kind === "skipper" && hostAuthorized) { @@ -1806,7 +1884,9 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread if (!config.can_draft) result = "Error: Gmail draft access is not granted to this channel."; else result = JSON.stringify(await createGmailDraft(account, String(args.to || ""), String(args.subject || ""), String(args.body || ""), turnSignal)); } else if (name === "call_skipper" && agent?.kind === "channel") result = callSkipper(agent, channelId, threadRootId, input); - else result = `Error: tool ${name} is not available.`; + else result = agent?.kind === "skipper" && ["create_channel", "list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations"].includes(name) + ? `Error: this user is not authorized to use ${name} in this channel.` + : `Error: tool ${name} is not available.`; } catch (error) { if ((error as Error).name === "AbortError") throw error; result = `Error: ${(error as Error).message}`; @@ -1894,10 +1974,9 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread // Skipper model completes the boundary work but forgets call_agent, the // harness re-enters the resident automatically with the concrete result. const residentEscalation = Boolean(q1("SELECT from_agent_id FROM escalations WHERE id=? AND from_agent_id IS NOT NULL", escalationId)); - if (residentEscalation && !handedBack && agentForChannel(channelId)?.kind === "channel") { - const evidence = lastCompletedTool && lastCompletedTool.name !== "call_agent" - ? `${lastCompletedTool.name}: ${lastCompletedTool.result}` - : responseBody; + const boundaryEvidence = lastCompletedTool && !["call_agent", "ask_user"].includes(lastCompletedTool.name) ? lastCompletedTool : null; + if (residentEscalation && boundaryEvidence && !handedBack && agentForChannel(channelId)?.kind === "channel") { + const evidence = `${boundaryEvidence.name}: ${boundaryEvidence.result}`; const automatic = callAgent( agent, channelId, diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 0ae1b93..a9d7733 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -122,6 +122,8 @@ const hostWorldRoot = (channelId: number): string => join(DATA_DIR, "channels", const hostWorkspace = channelWorkspacePath; const hostFiles = channelFilesPath; const workspaceMirrorRefreshes = new Map>(); +let appleNetworkRepair: Promise | null = null; +let appleNetworkRepairAt = 0; function withChannelLock(channelId: number, fn: () => Promise): Promise { const previous = channelLocks.get(channelId) || Promise.resolve(); @@ -572,6 +574,53 @@ async function isolated(args: string[], computer: ChannelComputer, user: "agent" return spawnCollected(invocation.command, invocation.args, { ...opts, env: invocation.env || opts.env }); } +const APPLE_GUEST_NETWORK_CHECK = "test \"$(cat /sys/class/net/eth0/operstate 2>/dev/null)\" = up && ip route show default | grep -q ."; + +async function appleGuestNetworkHealthy(computer: ChannelComputer): Promise { + if (computer.backend !== "apple") return true; + const checked = await isolated(["/bin/sh", "-lc", APPLE_GUEST_NETWORK_CHECK], computer, "root", "/", { timeoutMs: 15_000 }); + return checked.code === 0; +} + +/** Apple container 1.1.0 can leave every machine reported as running while + * its shared vmnet service has detached their NICs. Restart only that service, + * then reboot the affected VM so its durable disk receives a fresh attachment. */ +async function repairAppleGuestNetwork(computer: ChannelComputer): Promise { + if (computer.backend !== "apple") return; + if (!appleNetworkRepair) { + appleNetworkRepair = (async () => { + // Avoid repeatedly bouncing shared vmnet while several machines detect + // the same fleet-wide outage during one reconciliation pass. + if (now() - appleNetworkRepairAt > 30_000) { + if (platform() === "darwin") { + const label = `gui/${process.getuid?.() ?? 501}/com.apple.container.container-network-vmnet.default`; + const kicked = await spawnCollected("/bin/launchctl", ["kickstart", "-k", label], { timeoutMs: 30_000 }); + if (kicked.code !== 0) throw new Error(kicked.stderr.toString("utf8").trim() || "Apple shared VM network service could not restart"); + } + const started = await apple(["system", "start"], { timeoutMs: 90_000 }); + if (started.code !== 0) throw new Error(started.stderr.toString("utf8").trim() || "Apple container services could not restart"); + appleNetworkRepairAt = now(); + } + })().finally(() => { appleNetworkRepair = null; }); + } + await appleNetworkRepair; + const competingCommands = Number(q1(`SELECT COUNT(*) n FROM channel_computer_obligations + WHERE channel_id=? AND kind='command' AND status='active'`, computer.channel_id)?.n || 0); + const runningTurns = Number(q1("SELECT COUNT(*) n FROM agent_turns WHERE channel_id=? AND state='running'", computer.channel_id)?.n || 0); + if ([...terminalSessions.values()].some((session) => session.channelId === computer.channel_id) + || competingCommands > 1 || runningTurns > 1) { + throw new Error("resident computer network is unavailable; automatic repair is waiting for concurrent work to finish"); + } + const stopped = await apple(["machine", "stop", computer.machine_id], { timeoutMs: 90_000 }); + if (stopped.code !== 0 && !/not running|stopped/i.test(Buffer.concat([stopped.stderr, stopped.stdout]).toString("utf8"))) { + throw new Error(stopped.stderr.toString("utf8").trim() || "network repair could not stop the channel computer"); + } + const restarted = await apple(["machine", "run", "-n", computer.machine_id, "--", ...guestWords("/bin/sh", "-lc", APPLE_GUEST_NETWORK_CHECK)], { timeoutMs: 90_000 }); + if (restarted.code !== 0 || !await appleGuestNetworkHealthy(computer)) throw new Error("channel computer network remained unavailable after automatic repair"); + run("UPDATE channel_computers SET observed_state='running',provision_status='ready',last_health=?,last_error='',updated=? WHERE channel_id=?", now(), now(), computer.channel_id); + recordComputerActivity(computer.channel_id, "Recovered the resident computer's network without replacing its Linux disk.", "complete"); +} + const isolatedBackend = (computer: ChannelComputer): boolean => ["apple", "oci"].includes(computer.backend); const guestAgentIds = (computer: ChannelComputer): { uid: string; gid: string } => computer.backend === "apple" ? { uid: String(process.getuid?.() ?? 501), gid: String(process.getgid?.() ?? 20) } @@ -794,6 +843,7 @@ export async function ensureChannelComputerRunning(channelId: number, reason = " await syncHostChangesToGuest(computer); const inspection = await inspectApple(computer.machine_id); recordObserved(computer, inspection); + if (!await appleGuestNetworkHealthy(computer)) await repairAppleGuestNetwork(computer); } else if (computer.backend === "oci") { await ensureOciProvisioned(computer); computer = channelComputer(channelId)!; @@ -1481,6 +1531,13 @@ async function reconcileOne(computer: ChannelComputer): Promise { } return; } + if (computer.backend === "apple" && !await appleGuestNetworkHealthy(computer)) { + if (!canStopChannelComputer(computer.channel_id)) { + throw new Error("resident computer network is unavailable; automatic repair is waiting for its active work to stop"); + } + await repairAppleGuestNetwork(computer); + computer = channelComputer(computer.channel_id)!; + } const guest = await inspectGuestObligations(computer); run("UPDATE channel_computers SET pressure_json=?,last_health=?,updated=? WHERE channel_id=?", JSON.stringify(guest.pressure), now(), now(), computer.channel_id); if (guest.resident) upsertObligation(computer.channel_id, "guest-runtime", "detected", "resident", guest.details.join("; ")); diff --git a/src/server/cowork-contract.ts b/src/server/cowork-contract.ts new file mode 100644 index 0000000..e6b77a6 --- /dev/null +++ b/src/server/cowork-contract.ts @@ -0,0 +1,114 @@ +import { deleteWorkspaceEntry, listWorkspaceFiles, readWorkspaceTextFile, syncWorkspaceArtifacts } from "./agents.ts"; + +export type CoworkContext = { + kind: "file" | "folder"; + path: string; + surface: "notes" | "whiteboards" | "code" | "docs" | "presentations"; +}; + +const SURFACES = new Set(["notes", "whiteboards", "code", "docs", "presentations"]); + +function safeCoworkPath(input: string): string | null { + const path = String(input || "").trim().replace(/^\/+/, ""); + const parts = path.split("/"); + if (!path || parts.some((part) => !part || part === "." || part === ".." || part !== part.trim() || /[\\\0-\x1f\x7f]/.test(part))) return null; + return path; +} + +/** Cowork writes one visible, server-validated marker onto the root message. + * Re-derive the surface from that durable marker on every later turn so a + * follow-up, queued turn, or process restart cannot lose its file contract. */ +export function coworkContextFromRootBody(body: string): CoworkContext | null { + const match = /^Working (file|folder): \/workspace\/(.+)$/m.exec(String(body || "")); + if (!match) return null; + const path = safeCoworkPath(match[2]); + if (!path) return null; + const surface = path.split("/")[0] as CoworkContext["surface"]; + if (!SURFACES.has(surface)) return null; + return { kind: match[1] as "file" | "folder", path, surface }; +} + +export function coworkFormatContract(path: string, folderContext: boolean): string { + const safePath = safeCoworkPath(path); + const surface = safePath?.split("/")[0] as CoworkContext["surface"] | undefined; + if (!surface || !SURFACES.has(surface)) return ""; + const target = folderContext ? `the /workspace/${safePath} folder` : `/workspace/${safePath}`; + if (surface === "notes" || surface === "docs") { + return [ + `Cowork ${surface === "docs" ? "Docs" : "Notes"} contract for ${target} — follow it exactly:`, + "- The editable source of truth is Markdown. Create or update `.md` files only in this Cowork surface.", + "- Do not create HTML, PDF, DOCX, PPTX, or another substitute as the working file. 1Helm renders Markdown and provides exports itself.", + "- Keep any pre-existing user files intact; if the selected file has an incompatible extension, create a neighboring `.md` replacement instead of deleting it.", + ].join("\n"); + } + if (surface === "presentations") { + return [ + `Cowork Presentations contract for ${target} — follow it exactly:`, + "- Create exactly one editable deck named `.slides.json`. Do not create HTML, PPTX, PDF, local servers, or extra presentation files.", + "- Preferred schema: `{" + ' "theme": { "primary": "#hex", "background": "#hex", "text": "#hex", "accent": "#hex" }, "slides": [{ "title": "Slide title", "body": "Slide body" }] }' + "`.", + "- Native schema: `{" + ' "type": "1helm-slides", "version": 3, "printableArea": { "width": 1500, "height": 1000 }, "slides": [{ "id": "...", "name": "Slide title", "scene": { "elements": [], "appState": {}, "files": {} } }] }' + "`.", + "- Verify the JSON parses before finishing. The Presentations tab owns rendering and PDF export.", + ].join("\n"); + } + if (surface === "whiteboards") { + return [ + `Cowork Whiteboard contract for ${target} — follow it exactly:`, + "- Create exactly one editable board named `.whiteboard.json`. Do not create HTML, an image substitute, or an ad-hoc schema.", + '- Use an Excalidraw scene: `{ "type": "excalidraw", "version": 2, "elements": [], "appState": {}, "files": {} }`.', + "- Verify the JSON parses before finishing.", + ].join("\n"); + } + return [ + `Cowork Code contract for ${target}:`, + "- Use normal source files appropriate to the requested language or project. Preserve existing project conventions and verify the result.", + ].join("\n"); +} + +const surfacePrefix = (context: CoworkContext): string => `workspace/${context.surface}/`; + +export function snapshotCoworkSurface(channelId: number, context: CoworkContext): Set { + const prefix = surfacePrefix(context); + return new Set(listWorkspaceFiles(channelId).filter((entry) => entry.kind === "file" && entry.path.startsWith(prefix)).map((entry) => entry.path)); +} + +function compatibleCoworkFile(channelId: number, context: CoworkContext, path: string): boolean { + const lower = path.toLowerCase(); + if (context.surface === "code") return true; + if (context.surface === "notes" || context.surface === "docs") return lower.endsWith(".md"); + try { + if (context.surface === "whiteboards") { + if (!lower.endsWith(".whiteboard.json")) return false; + const parsed = JSON.parse(readWorkspaceTextFile(channelId, path).content) as Record; + return parsed.type === "excalidraw" && Number(parsed.version) === 2 && Array.isArray(parsed.elements); + } + if (!lower.endsWith(".slides.json")) return false; + const parsed = JSON.parse(readWorkspaceTextFile(channelId, path).content) as Record; + if (!Array.isArray(parsed.slides)) return false; + if (parsed.type === "1helm-slides") { + return Number(parsed.version) === 3 && parsed.slides.every((slide) => { + if (!slide || typeof slide !== "object") return false; + const scene = (slide as Record).scene; + return Boolean(scene && typeof scene === "object" && Array.isArray((scene as Record).elements)); + }); + } + return parsed.slides.every((slide) => slide != null && typeof slide === "object"); + } catch { return false; } +} + +/** Reject only files born during this command. Pre-existing user files are + * never removed, even when they predate and violate today's Cowork contract. */ +export function enforceCoworkCommandOutput(channelId: number, threadId: number | null, context: CoworkContext, before: Set): string | null { + const prefix = surfacePrefix(context); + const created = listWorkspaceFiles(channelId) + .filter((entry) => entry.kind === "file" && entry.path.startsWith(prefix) && !before.has(entry.path)) + .map((entry) => entry.path); + const rejected = created.filter((path) => !compatibleCoworkFile(channelId, context, path)); + for (const path of rejected) deleteWorkspaceEntry(channelId, path); + if (!rejected.length) return null; + syncWorkspaceArtifacts(channelId, threadId || null, "agent"); + const expected = context.surface === "presentations" ? "one valid `.slides.json` deck" + : context.surface === "whiteboards" ? "one valid `.whiteboard.json` Excalidraw scene" + : context.surface === "docs" || context.surface === "notes" ? "Markdown `.md`" + : "normal source files"; + return `Error: Cowork rejected and removed newly-created incompatible output (${rejected.map((path) => `/${path}`).join(", ")}). Create ${expected} in /workspace/${context.surface}; do not substitute HTML, PPTX, PDF, or an invented schema. Pre-existing user files were not touched.`; +} diff --git a/src/server/index.ts b/src/server/index.ts index 232678f..bb76baf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -80,6 +80,7 @@ import { photonSetupStatus, startPhotonSetup } from "./photon-auth.ts"; import { completeGmailConnection, gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; import { cancelMnemosyneRuntimePreparation, mnemosyneAvailable, prepareMnemosyneRuntime } from "./memory.ts"; import { attachCoworkClient, coworkPresence, coworkViewerUsernames, flushCoworkDocuments, normalizeCoworkFolderPath, normalizeCoworkPath } from "./cowork-collaboration.ts"; +import { coworkFormatContract } from "./cowork-contract.ts"; import { runImprovementPass, scheduleAgentReview, startImprovementLoop } from "./improvements.ts"; import { runThreadAuditPass, startThreadAuditLoop } from "./thread-audit.ts"; import { startFollowupLoop, threadFollowupView, bumpThreadFollowup } from "./followups.ts"; @@ -170,35 +171,6 @@ const SECURITY_HEADERS: Record = { // Keep camera and location denied, but allow this origin to request the mic. "permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)", }; -/** Cowork renderers accept exact file contracts. When a Cowork request targets - * the presentations or whiteboards world, tell the agent the format up front - * so it produces files the tab can actually open. */ -function coworkFormatContract(path: string, folderContext: boolean): string { - const inPresentations = folderContext ? (path === "presentations" || path.startsWith("presentations/")) : path.startsWith("presentations/"); - const inWhiteboards = folderContext ? (path === "whiteboards" || path.startsWith("whiteboards/")) : path.startsWith("whiteboards/"); - if (inPresentations || (!folderContext && path.endsWith(".slides.json"))) { - return [ - "Format contract for /workspace/presentations — follow it exactly:", - "- Create exactly one file per deck, named `.slides.json`. No .html decks, no ad-hoc schemas, no local web servers, no extra files.", - "- The Presentations tab renders 1Helm's Excalidraw-based deck format:", - ' `{ "type": "1helm-slides", "version": 3, "slides": [{ "name": "Slide title", "scene": { "elements": [...], "appState": {}, "files": {} } }] }`', - ' where `elements` are Excalidraw elements (type "text", "rectangle", "ellipse", "arrow", …) positioned on a 1500×1000 canvas.', - '- Simpler and recommended unless you know Excalidraw JSON: `{ "theme": { "primary": "#hex", "background": "#hex", "text": "#hex", "accent": "#hex" }, "slides": [{ "title": "Slide title", "body": "Slide body text" }] }` — 1Helm lays these out with the theme colors, sized text, and bullets (lines starting with "- " render as bullet rows). Supply a theme that fits the subject.', - "- Verify your JSON parses before finishing.", - ].join("\n"); - } - if (inWhiteboards || (!folderContext && path.endsWith(".whiteboard.json"))) { - return [ - "Format contract for /workspace/whiteboards — follow it exactly:", - "- Create exactly one file per board, named `.whiteboard.json`. No .html, no ad-hoc schemas.", - '- The Whiteboard tab renders an Excalidraw scene: `{ "type": "excalidraw", "version": 2, "elements": [...], "appState": {}, "files": {} }`', - ' where `elements` are Excalidraw elements (type "text", "rectangle", "ellipse", "arrow", "line", "freedraw", …).', - "- Verify your JSON parses before finishing.", - ].join("\n"); - } - return ""; -} - const json = (res: ServerResponse, code: number, body: unknown): void => { const s = JSON.stringify(body); res.writeHead(code, { "content-type": "application/json", ...SECURITY_HEADERS }); diff --git a/src/server/skills.ts b/src/server/skills.ts index 282bdd1..295bc92 100644 --- a/src/server/skills.ts +++ b/src/server/skills.ts @@ -169,12 +169,17 @@ export function readAgentSkill(agentId: number, slugInput: string): Row { }; } -/** The model gets a compact factual inventory, not every skill's procedure. - * It can choose a skill and load its full instructions through read_skill. */ +/** The model gets compact usable metadata, not every skill's full procedure. + * Names and descriptions are essential capability awareness: a count of + * categories made Skipper deny calendar/personal work it already owned. */ export function agentSkillContext(agentId: number, _task = ""): string { const available = skillsForAgent(agentId).filter((skill) => !skill.arsenal_locked); - const categories = [...new Set(available.map((skill) => String(skill.category || "general")))].sort(); - return ``; + return [ + ``, + ...available.map((skill) => `- ${skill.slug}: ${skill.name} — ${String(skill.description || "").replace(/\s+/g, " ").trim().slice(0, 240)}`), + "Use list_skills/read_skill to load a full procedure when its metadata matches the task. Do not ask the user to discover or choose skills for you.", + "", + ].join("\n"); } export function templateForSlug(slug: string): Row | undefined { diff --git a/test/autonomy-platform.mjs b/test/autonomy-platform.mjs index e7e0116..74961dd 100644 --- a/test/autonomy-platform.mjs +++ b/test/autonomy-platform.mjs @@ -16,6 +16,8 @@ const { windowsSystemAccount } = await import("../src/server/channel-computers.t const turns = await import("../src/server/turns.ts"); const catalog = await import("../src/server/skill-catalog.ts"); const history = await import("../src/server/history.ts"); +const agents = await import("../src/server/agents.ts"); +const cowork = await import("../src/server/cowork-contract.ts"); test("ask_user rejects routine ambiguity and accepts only evidenced human blockers", () => { assert.equal(validateAskUserInput({ questions: [{ question: "Which?", options: [{ label: "A" }, { label: "B" }] }] }).valid, false); @@ -31,14 +33,25 @@ test("#main is a database- and tool-level resident-free authority channel", () = const mainThread = run("INSERT INTO threads (root_message_id,channel_id,status,title,summary,opened_at,updated_at) VALUES (?,?,'open','','',?,?)", mainRoot, main, now(), now()).lastInsertRowid; const ordinary = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES ('ordinary','ordinary','channel','','','active',?)", now()).lastInsertRowid; const skipperBot = run("INSERT INTO bots (name,model,created) VALUES ('authority-skipper','mock',?)", now()).lastInsertRowid; - run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'skipper','authority-skipper','ready',?)", skipperBot, now()); + const skipperId = run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'skipper','authority-skipper','ready',?)", skipperBot, now()).lastInsertRowid; + run("INSERT INTO agent_profiles (agent_id,purpose,instructions,updated) VALUES (?,'Workspace-wide chief of staff','Own cross-channel outcomes.',?)", skipperId, now()); const residentBot = run("INSERT INTO bots (name,model,created) VALUES ('authority-resident','mock',?)", now()).lastInsertRowid; const resident = run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'channel','authority-resident','ready',?)", residentBot, now()).lastInsertRowid; run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", resident, ordinary, now()); - const mainTools = runtimeToolNamesForChannel(skipperBot, main, true); + const mainTools = runtimeToolNamesForChannel(skipperBot, main, false, ownerId); assert(!mainTools.includes("call_agent")); assert(!mainTools.includes("invite_agent")); assert(mainTools.includes("inspect_web_source")); + assert(mainTools.includes("create_channel"), "the personal-main owner receives native channel creation"); + assert(mainTools.includes("schedule_workflow") && mainTools.includes("list_workflows") && mainTools.includes("set_workflow_status"), "Skipper can coordinate durable schedules across scoped domain channels"); + const nonOwnerId = run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('authority-member','x','Member',0,?)", now()).lastInsertRowid; + run("INSERT INTO members (channel_id,user_id,last_read) VALUES (?,?,0)", main, nonOwnerId); + assert(!runtimeToolNamesForChannel(skipperBot, main, false, nonOwnerId).includes("create_channel"), "a non-owner member is never advertised an unauthorized channel-creation tool"); + assert(runtimeToolNamesForChannel(skipperBot, ordinary, true, nonOwnerId).includes("create_channel"), "Captain-authorized ordinary-channel control remains available"); + const skipperPrompt = runtimePromptTiersForChannel(skipperBot, main, false, "I need help with my schedule", ownerId); + assert.match(skipperPrompt.identity, /chief of staff[\s\S]*cross-channel/i); + assert.match(skipperPrompt.operating, /schedules, reminders, tasks, goals[\s\S]*coordinate the unified cross-channel view/i); + assert.doesNotMatch(skipperPrompt.operating, /not personal calendars|not personal scheduling/i); assert.throws(() => run("INSERT INTO thread_agent_guests (thread_id,agent_id,status,created) VALUES (?,?,'active',?)", mainThread, resident, now()), /cannot enter #main/i); db.exec("DROP TRIGGER trg_thread_guest_no_personal_main_insert; DROP TRIGGER trg_thread_guest_no_personal_main_update;"); run("INSERT INTO thread_agent_guests (thread_id,agent_id,status,created) VALUES (?,?,'active',?)", mainThread, resident, now()); @@ -117,13 +130,14 @@ test("a finalized turn is immutable to stale stream writers", () => { assert.equal(q1("SELECT status FROM agent_progress WHERE id=?", progressId).status, "complete"); }); -test("runtime exposes compact factual capabilities instead of injected playbooks", () => { +test("runtime exposes usable skill metadata and durable operating identity without injecting full playbooks", () => { seed(); const channelId = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES ('prompt-tiers','prompt-tiers','channel','','First purpose','active',?)", now()).lastInsertRowid; const botId = run("INSERT INTO bots (name,model,prompt,created) VALUES ('prompt-agent','mock','Patient domain partner.',?)", now()).lastInsertRowid; const agentId = run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'channel','prompt-agent','ready',?)", botId, now()).lastInsertRowid; run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", agentId, channelId, now()); run("INSERT INTO agent_profiles (agent_id,purpose,instructions,updated) VALUES (?,'Own prompt testing.','Patient domain partner.',?)", agentId, now()); + run("INSERT INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) SELECT ?,id,?,'Prompt inventory proof',1,? FROM skills WHERE slug='outcome-ownership'", agentId, agentId, now()); const first = runtimePromptTiersForChannel(botId, channelId, false, "inspect alpha"); run("UPDATE agent_profiles SET purpose='Changed volatile purpose' WHERE agent_id=?", agentId); const second = runtimePromptTiersForChannel(botId, channelId, false, "inspect beta"); @@ -133,8 +147,9 @@ test("runtime exposes compact factual capabilities instead of injected playbooks assert.match(first.operating, /isolated persistent Linux computer/i); assert.match(first.operating, /\/workspace/); assert.match(first.context, /skill-arsenal count=/i); + assert.match(first.context, /outcome-ownership: Outcome ownership/i); assert.doesNotMatch(first.context, /active-skill-playbooks|workspace-skill-catalog|### /i); - assert(first.identity.length + first.operating.length + first.context.length < 2_000, "capability map stays compact"); + assert(first.identity.length + first.operating.length + first.context.length < 15_000, "capability map remains metadata-bounded rather than injecting full procedures"); assert.match(first.context, /Own prompt testing/); assert.match(second.context, /Changed volatile purpose/); const tools = runtimeToolNamesForChannel(botId, channelId, false); @@ -142,6 +157,46 @@ test("runtime exposes compact factual capabilities instead of injected playbooks assert(tools.includes("search_channel_history") && tools.includes("read_channel_session")); }); +test("Cowork contracts survive follow-ups and reject only newly-created incompatible files", () => { + seed(); + const channelId = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES ('cowork-contract','cowork-contract','channel','','','active',?)", now()).lastInsertRowid; + agents.ensureChannelWorkspace(channelId); + const botId = run("INSERT INTO bots (name,model,prompt,created) VALUES ('cowork-contract-agent','mock','Resident.',?)", now()).lastInsertRowid; + const agentId = run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'channel','cowork-contract-agent','ready',?)", botId, now()).lastInsertRowid; + run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", agentId, channelId, now()); + run("INSERT INTO agent_profiles (agent_id,purpose,instructions,updated) VALUES (?,'Documents','Resident.',?)", agentId, now()); + const ownerId = run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('cowork-contract-owner','x','Owner',1,?)", now()).lastInsertRowid; + const rootId = run("INSERT INTO messages (channel_id,user_id,body,created) VALUES (?,?,?,?)", channelId, ownerId, "@cowork-contract-agent make the document\n\nWorking folder: /workspace/docs", now()).lastInsertRowid; + run("INSERT INTO threads (root_message_id,channel_id,status,title,summary,opened_at,updated_at) VALUES (?,?,'open','','',?,?)", rootId, channelId, now(), now()); + const followupId = run("INSERT INTO messages (channel_id,parent_id,user_id,body,created) VALUES (?,?,?,?,?)", channelId, rootId, ownerId, "fix the layout", now()).lastInsertRowid; + const runtimeAgent = q1("SELECT a.*,ac.channel_id,p.purpose,p.instructions FROM agents a JOIN agent_channels ac ON ac.agent_id=a.id LEFT JOIN agent_profiles p ON p.agent_id=a.id WHERE a.id=?", agentId); + const context = buildContext(q1("SELECT * FROM bots WHERE id=?", botId), runtimeAgent, channelId, followupId, rootId, false, false); + assert.match(context.find((message) => message.content.includes(""))?.content || "", /Docs[\s\S]*Markdown[\s\S]*\.md/i, "a follow-up re-derives the root Cowork contract without transient client context"); + + agents.createWorkspaceFile(channelId, "docs", "existing.html", "

keep me

"); + const docs = cowork.coworkContextFromRootBody("Working folder: /workspace/docs"); + const before = cowork.snapshotCoworkSurface(channelId, docs); + agents.createWorkspaceFile(channelId, "docs", "wrong.html", "

wrong

"); + agents.createWorkspaceFile(channelId, "docs", "right.md", "# Right\n"); + const rejected = cowork.enforceCoworkCommandOutput(channelId, Number(q1("SELECT id FROM threads WHERE root_message_id=?", rootId).id), docs, before); + assert.match(rejected, /rejected and removed[\s\S]*wrong\.html/i); + assert.equal(agents.readWorkspaceTextFile(channelId, "docs/existing.html").content, "

keep me

", "pre-existing incompatible user files are untouched"); + assert.equal(agents.readWorkspaceTextFile(channelId, "docs/right.md").content, "# Right\n"); + assert.throws(() => agents.readWorkspaceTextFile(channelId, "docs/wrong.html"), /not found/i); + + const presentations = cowork.coworkContextFromRootBody("Working folder: /workspace/presentations"); + const deckBefore = cowork.snapshotCoworkSurface(channelId, presentations); + agents.createWorkspaceFile(channelId, "presentations", "sample.pptx", "not really a pptx"); + agents.createWorkspaceFile(channelId, "presentations", "sample.slides.json", JSON.stringify({ theme: { primary: "#123456" }, slides: [{ title: "Ready", body: "Works" }] })); + assert.match(cowork.enforceCoworkCommandOutput(channelId, null, presentations, deckBefore), /sample\.pptx/); + assert.doesNotThrow(() => JSON.parse(agents.readWorkspaceTextFile(channelId, "presentations/sample.slides.json").content)); + + const whiteboards = cowork.coworkContextFromRootBody("Working file: /workspace/whiteboards/map.whiteboard.json"); + const boardBefore = cowork.snapshotCoworkSurface(channelId, whiteboards); + agents.createWorkspaceFile(channelId, "whiteboards", "map.whiteboard.json", JSON.stringify({ type: "excalidraw", version: 2, elements: [], appState: {}, files: {} })); + assert.equal(cowork.enforceCoworkCommandOutput(channelId, null, whiteboards, boardBefore), null); +}); + test("resident raw transcript search is semantic, exact, readable, and channel-isolated", () => { const ownerId = run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('history-owner','x','History Owner',1,?)", now()).lastInsertRowid; const channelId = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created_by,created) VALUES ('history-home','history-home','channel','','Recall prior sessions','active',?,?)", ownerId, now()).lastInsertRowid; @@ -160,7 +215,7 @@ test("resident raw transcript search is semantic, exact, readable, and channel-i assert(exact.results.every((entry) => entry.thread_root_id === rootId)); const semantic = history.searchChannelHistory(agent, channelId, { query: "copper lighthouse", mode: "semantic" }); assert(semantic.results.some((entry) => entry.message_id === rootId)); - assert.equal(q1("SELECT COUNT(*) n FROM transcript_memory_index WHERE agent_id=?", agentId).n, 2); + assert([0, 2].includes(Number(q1("SELECT COUNT(*) n FROM transcript_memory_index WHERE agent_id=?", agentId).n)), "the optional semantic runtime indexes both messages when available and the exact/keyword fallback remains usable when absent"); const session = history.readChannelThread(agent, channelId, rootId); assert.equal(session.thread_id, threadId); assert.equal(session.messages.length, 2); diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index 776fa85..23c14bd 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -127,6 +127,29 @@ try { ok(brand.title.includes("1Helm") && brand.appName === "1Helm" && !brand.body.includes("1Herd"), "the browser presents the product as 1Helm throughout"); ok(brand.favicon === "/brand/1helm-sailboat.png" && brand.workspaceLogo === "/brand/1helm-sailboat.png", "the sailboat is the web favicon and default customizable workspace image"); + const sidebarAgentIdentity = await page.evaluate(async (targetChannelId, skipperAgentId) => { + const bundleSrc = document.querySelector('script[type="module"][src*="bundle.js"]')?.getAttribute("src"); + if (!bundleSrc) throw new Error("App bundle module was not found."); + const app = await import(bundleSrc); + const target = app.S.channels.find((candidate) => candidate.id === targetChannelId); + if (!target?.agent) throw new Error("Target resident was not found."); + target.agent.status = "ready"; + const rejected = app.applyAgentStatusEvent({ channelId: target.id, agentId: skipperAgentId, status: "working" }); + app.renderSidebar(); + const dotsAfterSkipper = document.querySelectorAll('[data-sidebar="desktop"] .channel-working-dots').length; + const accepted = app.applyAgentStatusEvent({ channelId: target.id, agentId: target.agent.id, status: "working" }); + app.renderSidebar(); + const workingRows = [...document.querySelectorAll('[data-sidebar="desktop"] .channel-working-dots')] + .map((dots) => dots.closest("button")?.getAttribute("aria-label") || ""); + app.applyAgentStatusEvent({ channelId: target.id, agentId: target.agent.id, status: "ready" }); + app.renderSidebar(); + return { rejected, accepted, dotsAfterSkipper, workingRows }; + }, channel.id, mainChannel.agent.id); + ok(!sidebarAgentIdentity.rejected && sidebarAgentIdentity.dotsAfterSkipper === 0 + && sidebarAgentIdentity.accepted && sidebarAgentIdentity.workingRows.length === 1 + && sidebarAgentIdentity.workingRows[0].startsWith(`${channel.name}, working`), + `a Skipper turn cannot animate the resident's sidebar row, while one resident turn animates exactly one row (${JSON.stringify(sidebarAgentIdentity)})`); + await page.setRequestInterception(true); const interceptReadyUpdate = (request) => { if (request.url() === `${base}/api/app/update` && request.method() === "GET") { diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index e8cbe1d..0e35365 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -64,6 +64,11 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive assert.equal(readFileSync(join(dataDir, "channels", String(alpha.channelId), "files", "guest.txt"), "utf8"), "upload-from-guest", "guest /workspace/files mirrors to the Files tree"); assert.equal(existsSync(join(fakeState, "machines", betaComputer.machine_id, "workspace", "agent.txt")), false, "channel B cannot see channel A's workspace"); + writeFileSync(join(fakeState, "machines", alphaComputer.machine_id, ".network-down"), "1"); + result = await computers.runChannelCommand(alpha.channelId, "printf network-recovered > network.txt"); + assert.equal(result.exit_code, 0, "a command repairs an Apple VM whose reported-running NIC is actually down"); + assert.equal(readFileSync(join(fakeState, "machines", alphaComputer.machine_id, "workspace", "network.txt"), "utf8"), "network-recovered"); + const betaRoot = join(fakeState, "machines", betaComputer.machine_id); db.run("UPDATE channel_computers SET last_used=? WHERE channel_id=?", Date.now() - 120_000, beta.channelId); writeFileSync(join(betaRoot, ".uncertain-quiescence"), "1"); diff --git a/test/desktop.mjs b/test/desktop.mjs index 4162a3e..ff06e66 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -17,7 +17,6 @@ test("desktop first launch can connect to an existing workspace or start this PC assert.equal(normalizeRemoteOrigin("https://acme.1helm.com/path"), "https://acme.1helm.com"); assert.equal(normalizeRemoteOrigin("http://acme.1helm.com"), ""); assert.equal(isHostedWorkspaceOrigin("https://acme.1helm.com"), true); - assert.equal(isHostedWorkspaceOrigin("https://demo.1helm.com"), false); assert.equal(allowedRemoteUrl("https://private.example/app", "https://private.example"), true); assert.equal(allowedRemoteUrl("https://other.example/app", "https://private.example"), false); assert.deepEqual(desktopGatewayAction("https://desktop-action.1helm.invalid/setup"), { type: "setup" }); diff --git a/test/fake-container.mjs b/test/fake-container.mjs index d6c0cbb..3975e3a 100644 --- a/test/fake-container.mjs +++ b/test/fake-container.mjs @@ -31,7 +31,10 @@ if (args[0] === "system" && args[1] === "status") { process.stdout.write(JSON.stringify({ status: "running" })); process.exit(0); } -if (args[0] === "system" && args[1] === "start") process.exit(0); +if (args[0] === "system" && args[1] === "start") { + for (const entry of readdirSync(join(stateRoot, "machines"))) rmSync(join(stateRoot, "machines", entry, ".network-down"), { force: true }); + process.exit(0); +} if (args[0] === "image" && args[1] === "inspect") { process.stdout.write(JSON.stringify({ reference: args[2] })); process.exit(0); @@ -165,6 +168,10 @@ if (script.includes("SERVICES=") || script.includes("MemAvailable")) { process.stdout.write(`SERVICES=0\nTIMERS=${timer}\nCRON=0\nJOBS=0\nSOCKETS=0\nPATHS=0\nLOAD1=0.01\nMEM_AVAILABLE_KB=1048576\nDISK_USED_PERCENT=1\n`); process.exit(0); } +if (script.includes("/sys/class/net/eth0/operstate") && script.includes("ip route show default")) { + if (existsSync(join(root, ".network-down"))) fail("guest network link is down"); + process.exit(0); +} if (script.includes("apt-get update") && script.includes("DEBIAN_FRONTEND")) { process.stdout.write("fake unattended update complete\n"); process.exit(0); diff --git a/test/native-world.mjs b/test/native-world.mjs index abcdb25..f7b81da 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -818,6 +818,23 @@ try { return residentReplies.length >= 2 && handoff ? { residentReplies: residentReplies.length, handoff } : null; }, "automatic Skipper-to-resident return", 20_000); ok(Boolean(automaticReturn), "runtime automatically returns a resident escalation after Skipper unblocks it even when the Skipper model omits call_agent"); + const escalationLoopDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const escalationThread = escalationLoopDb.prepare("SELECT id FROM threads WHERE root_message_id=?").get(escalationRoot.body.message.id); + const openEscalation = escalationLoopDb.prepare("SELECT id FROM escalations WHERE thread_id=? AND from_agent_id=? ORDER BY id DESC LIMIT 1").get(escalationThread.id, afterRestart.agent.id); + if (openEscalation) escalationLoopDb.prepare("UPDATE escalations SET status='open' WHERE id=?").run(openEscalation.id); + escalationLoopDb.close(); + const duplicateEscalationRequest = await api(`/api/channels/${launch.id}/messages`, { body: { body: `@${afterRestart.agent.name} call skipper to run whoami`, parentId: escalationRoot.body.message.id } }, captain); + await waitFor(() => { + const guardDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const turn = guardDb.prepare("SELECT state FROM agent_turns WHERE trigger_id=?").get(duplicateEscalationRequest.body.message.id); + guardDb.close(); + return turn?.state === "completed"; + }, "duplicate resident escalation guard"); + const guardedEscalationDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const escalationCount = guardedEscalationDb.prepare("SELECT COUNT(*) n FROM escalations WHERE thread_id=? AND from_agent_id=?").get(escalationThread.id, afterRestart.agent.id).n; + if (openEscalation) guardedEscalationDb.prepare("UPDATE escalations SET status='resolved' WHERE id=?").run(openEscalation.id); + guardedEscalationDb.close(); + ok(duplicateEscalationRequest.status === 200 && escalationCount === 1, "an open resident escalation cannot dispatch the same resident-to-Skipper loop again"); // Skipper hand-back: after unblocking, Skipper must re-invoke the resident via call_agent // so the Captain never has to re-tag the agent (symmetric with call_skipper). diff --git a/test/site.mjs b/test/site.mjs index 1b5fa57..5807a91 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { request } from "node:http"; import { createServer } from "node:net"; import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -28,15 +27,6 @@ const releaseFixture = { }; const freePort = () => new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); }); const waitFor = async (url) => { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { const result = await fetch(url); if (result.ok) return result; } catch {} await new Promise((resolve) => setTimeout(resolve, 80)); } throw new Error(`Timed out: ${url}`); }; -const requestWithHost = (port, path, host) => new Promise((resolve, reject) => { - const req = request({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => { - res.resume(); - res.once("end", () => resolve(res)); - }); - req.once("error", reject); - req.end(); -}); - test("standalone 1helm.com website serves independent product and documentation surface", async () => { const port = await freePort(); const child = spawn(process.execPath, ["site/server.mjs"], { cwd: root, env: { ...process.env, SITE_PORT: String(port), SITE_RELEASE_METADATA_JSON: JSON.stringify(releaseFixture) }, stdio: ["ignore", "pipe", "pipe"] }); @@ -53,11 +43,7 @@ test("standalone 1helm.com website serves independent product and documentation const manual = await (await fetch(`${base}/manual`)).text(); assert.match(manual, /The Ship's/); assert.match(manual, /Do I really need a dedicated computer/); - assert.match(manual, /retired pre-OCI sandbox is no longer running/i); - assert.doesNotMatch(manual, /it's a public sandbox/i); - const retiredDemo = await requestWithHost(port, "/manual?from=demo", "demo.1helm.com"); - assert.equal(retiredDemo.statusCode, 301); - assert.equal(retiredDemo.headers.location, "https://1helm.com/manual?from=demo"); + assert.doesNotMatch(manual, /public sandbox|retired pre-OCI sandbox/i); const privacy = await (await fetch(`${base}/privacy`)).text(); assert.match(privacy, /build@1helm\.com/); assert.match(privacy, /device tokens are encrypted at rest/i);