From c858cb816ca049437932f4e143c11c77091bd89d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 14:42:08 +0000 Subject: [PATCH] Phase 3: real code work in evOo (git tools, workspace mount, push policy) Add git, gh, openssh-client, curl, and sudo to the computer image with a docker smoke test. Mount deployment-owner project workspacePath at /home/rakazo/project, align /home/rakazo file paths with shell, and resolve default branch from origin/HEAD. Inject the owner GitHub token from encrypted deployment settings only into project computers; enforce branch-only push, block force-push, and require approval before the first push or gh pr in a run. Add runaway guards (tool fuse and stall pause), collapse live narration, fix streaming progress merge, DE run strings, and Lead as the project main Oo name. Fix evo-deploy test flake. Co-authored-by: cynarAI --- apps/api/src/router.ts | 22 +++ apps/web/e2e/project-main-agent.spec.ts | 2 +- apps/web/e2e/spaces.spec.ts | 6 +- .../components/voice/VoiceAwarenessStrip.tsx | 34 +++-- apps/web/src/lib/bot-persona.ts | 4 +- apps/web/src/locales/de/messages.po | 8 ++ docs/PRODUCTION-READINESS.md | 20 +++ infra/sandboxes/computer/Dockerfile | 8 ++ .../computer/computer-image-tools.test.sh | 11 ++ .../supervisor/src/computer-spec.test.ts | 22 +++ .../sandboxes/supervisor/src/computer-spec.ts | 68 +++++++-- infra/sandboxes/supervisor/src/index.ts | 8 +- packages/adapter-kit/src/interfaces.ts | 1 + packages/adapters/src/computer-lifecycle.ts | 11 ++ .../adapters/src/computer-support.test.ts | 6 + packages/adapters/src/computer-support.ts | 8 +- .../src/deployment-github-token.test.ts | 24 ++++ .../adapters/src/deployment-github-token.ts | 23 +++ packages/adapters/src/docker-sandbox.ts | 5 +- packages/adapters/src/executor.ts | 132 ++++++++++++++++-- .../adapters/src/git-shell-policy.test.ts | 48 +++++++ packages/adapters/src/git-shell-policy.ts | 81 +++++++++++ packages/adapters/src/pi-model-config.ts | 17 ++- .../src/pi-runtime-tool-dispatch.test.ts | 36 ++--- packages/adapters/src/pi-runtime.ts | 20 +-- .../adapters/src/project-workspace-bind.ts | 26 ++++ packages/contracts/src/domain.ts | 1 + packages/contracts/src/rpc.ts | 2 + packages/core/src/events.test.ts | 17 +++ packages/core/src/events.ts | 12 +- packages/core/src/git-checkout.test.ts | 5 +- packages/core/src/git-checkout.ts | 10 +- .../migration.sql | 1 + packages/db/prisma/schema.prisma | 1 + packages/db/src/main-agent.ts | 2 +- .../db/src/project-workspace-mount.test.ts | 11 ++ packages/db/src/project-workspace-mount.ts | 15 ++ packages/db/src/project-workspace.ts | 2 + .../testkit/src/computer-image-tools.test.ts | 32 +++++ packages/testkit/src/evo-deploy.test.ts | 2 +- 40 files changed, 680 insertions(+), 84 deletions(-) create mode 100755 infra/sandboxes/computer/computer-image-tools.test.sh create mode 100644 packages/adapters/src/deployment-github-token.test.ts create mode 100644 packages/adapters/src/deployment-github-token.ts create mode 100644 packages/adapters/src/git-shell-policy.test.ts create mode 100644 packages/adapters/src/git-shell-policy.ts create mode 100644 packages/adapters/src/project-workspace-bind.ts create mode 100644 packages/db/prisma/migrations/20261007120000_deployment_owner_github_token/migration.sql create mode 100644 packages/db/src/project-workspace-mount.test.ts create mode 100644 packages/db/src/project-workspace-mount.ts create mode 100644 packages/testkit/src/computer-image-tools.test.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 9165644..b9c73aa 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -984,6 +984,21 @@ export function createRouter(deps: RouterDeps) { "This Mac mode is only available when SANDBOX_PROVIDER=docker on a personal local app.", }); } + let ownerGithubTokenCipher: string | null | undefined; + if (input.ownerGithubToken !== undefined) { + if (input.ownerGithubToken === null) { + ownerGithubTokenCipher = null; + } else { + const stored = await deps.secrets.put(input.ownerGithubToken, { + operationId: "deployment-github-token", + traceId: "deployment-github-token", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }); + ownerGithubTokenCipher = stored.ciphertext; + } + } await deps.prisma.deploymentSettings.upsert({ where: { id: "default" }, create: { @@ -993,6 +1008,9 @@ export function createRouter(deps: RouterDeps) { signupAllowlist: (input.signupAllowlist ?? []).join(","), signupPolicyInitialized: true, computerHost: input.computerHost ?? undefined, + ...(ownerGithubTokenCipher !== undefined + ? { ownerGithubTokenCipher } + : {}), }, update: { ...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }), @@ -1001,6 +1019,9 @@ export function createRouter(deps: RouterDeps) { ? {} : { signupPolicyInitialized: true }), ...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }), + ...(ownerGithubTokenCipher !== undefined + ? { ownerGithubTokenCipher } + : {}), }, }); return deploymentDto(deps.prisma, deps.env.sandboxProvider); @@ -5744,6 +5765,7 @@ async function deploymentDto(prisma: PrismaClient, sandboxProvider: string) { ? settings.signupAllowlist.split(",").filter(Boolean) : [], hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher), + hasOwnerGithubToken: Boolean(settings?.ownerGithubTokenCipher), defaultProvider: settings?.defaultModelProvider ?? null, defaultModel: settings?.defaultModelId ?? null, computerHost: computerHostFor(settings?.computerHost, sandboxProvider), diff --git a/apps/web/e2e/project-main-agent.spec.ts b/apps/web/e2e/project-main-agent.spec.ts index 7d0fedd..c391b7b 100644 --- a/apps/web/e2e/project-main-agent.spec.ts +++ b/apps/web/e2e/project-main-agent.spec.ts @@ -27,7 +27,7 @@ test("a new project opens straight into the conversation with its own main agent // The project is one conversation, led by a main agent of its own. const projectEntry = projectSpace(projekte, "Launch").getByRole("button", { - name: "Chief, main conversation", + name: "Lead, main conversation", }); await expect(projectEntry).toBeVisible({ timeout: 30_000 }); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index e540978..1802608 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -44,7 +44,7 @@ test("Projects + opens new project dialog instead of group form", async ({ page ); await expect( projectSpace(projekte, "Customer project").getByRole("button", { - name: "Chief, main conversation", + name: "Lead, main conversation", }), ).toBeVisible(); await expect(page.getByTestId("project-start-main")).toHaveCount(0); @@ -169,7 +169,7 @@ test("a new space opens its main conversation and can be deleted from its menu", await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect( - projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }), + projectSpace(projekte, "Temporary").getByRole("button", { name: "Lead, main conversation" }), ).toBeVisible(); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); expect(await rpc>(page, "bots/list", {})).toEqual([ @@ -243,7 +243,7 @@ test("deleting the last bot in a space stays in the app after first use", async // The project's main conversation stays, so the project opens there. await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); await expect( - projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }), + projectSpace(projekte, "Side").getByRole("button", { name: "Lead, main conversation" }), ).toBeVisible(); await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click(); diff --git a/apps/web/src/components/voice/VoiceAwarenessStrip.tsx b/apps/web/src/components/voice/VoiceAwarenessStrip.tsx index 7e026e9..4510bb8 100644 --- a/apps/web/src/components/voice/VoiceAwarenessStrip.tsx +++ b/apps/web/src/components/voice/VoiceAwarenessStrip.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { BotAvatar } from "@rakazo/ui-web"; import { ArrowRightLeft, GitBranch, MessageCircle, Users } from "lucide-react"; import type { VoiceAwarenessEvent } from "../../lib/bot-persona"; @@ -13,6 +14,7 @@ export function VoiceAwarenessStrip({ botColor: string; botId: string; }) { + const { t } = useLingui(); const Icon = event.kind === "subagent" ? Users @@ -24,6 +26,19 @@ export function VoiceAwarenessStrip({ ? MessageCircle : Users; + const kindLabel = + event.kind === "subagent" + ? t`Collaborating` + : event.kind === "handoff" + ? t`Handoff` + : event.kind === "delegation" + ? t`Delegated` + : event.kind === "peer-reply" + ? t`Peer reply` + : event.kind === "working" + ? t`In progress` + : t`Aware`; + return (
- {awarenessKindLabel(event.kind)} + {kindLabel}

{label} @@ -43,20 +58,3 @@ export function VoiceAwarenessStrip({

); } - -function awarenessKindLabel(kind: VoiceAwarenessEvent["kind"]): string { - switch (kind) { - case "subagent": - return "Collaborating"; - case "handoff": - return "Handoff"; - case "delegation": - return "Delegated"; - case "peer-reply": - return "Peer reply"; - case "working": - return "In progress"; - default: - return "Aware"; - } -} diff --git a/apps/web/src/lib/bot-persona.ts b/apps/web/src/lib/bot-persona.ts index 42fa474..3cb2bdf 100644 --- a/apps/web/src/lib/bot-persona.ts +++ b/apps/web/src/lib/bot-persona.ts @@ -1,4 +1,6 @@ +import { msg } from "@lingui/core/macro"; import type { ThreadSnapshot } from "@rakazo/contracts"; +import { i18n } from "./i18n"; import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web"; import type { StagePresencePhase } from "../components/stage/StageHero"; import type { VoicePhase } from "../components/voice/VoiceStage"; @@ -462,7 +464,7 @@ function narrateWorking(archetype: PersonaArchetype, name: string): string { case "engineer": return `${name} is heads-down on this.`; default: - return `${name} is working on your request.`; + return i18n._(msg`${name} is working on your request.`); } } diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index 0db6e3d..1587bc8 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -93,6 +93,14 @@ msgstr "{0} ist da" msgid "{0} is working…" msgstr "{0} arbeitet…" +#: src/lib/bot-persona.ts +msgid "{name} is working on your request." +msgstr "{name} arbeitet an deiner Anfrage." + +#: src/components/voice/VoiceAwarenessStrip.tsx +msgid "In progress" +msgstr "In Arbeit" + #. placeholder {0}: format(size / 1024) #: src/lib/format-bytes.ts msgid "{0} KB" diff --git a/docs/PRODUCTION-READINESS.md b/docs/PRODUCTION-READINESS.md index cb95e25..c0669e0 100644 --- a/docs/PRODUCTION-READINESS.md +++ b/docs/PRODUCTION-READINESS.md @@ -154,6 +154,26 @@ Severity: **P0** ship blocker · **P1** major UX/trust · **P2** polish · **P3* | R2-6 | P2 | Deep link / reload matrix | **`route-deep-link-matrix`** | | R2-7 | P2 | Empty project / SessionUnavailable / teaching composer E2E | **`empty-project-state`**, **`session-unavailable`**, **`teaching-composer`** | +### Fixed in Phase 3 (real code work) + +| ID | Sev | Issue | Fix | +|----|-----|-------|-----| +| R3-git | P0 | Computer image missing git/gh/ssh/curl/sudo | `infra/sandboxes/computer/Dockerfile` + `computer-image-tools.test.sh` | +| R3-mount | P0 | `Space.workspacePath` not mounted in Oo | Bind at `/home/rakazo/project` for deployment owner project spaces | +| R3-path | P0 | `/home/rakazo/...` file paths doubled | `stripVirtualWorkspaceRoot` + tests | +| R3-branch | P0 | Default branch from checkout not remote | `readGitCheckout` uses `origin/HEAD` | +| R3-push | P0 | Unrestricted git push / force-push | `git-shell-policy.ts` + approval for first push/`gh pr` | +| R3-token | P0 | No owner GitHub credential path | Encrypted `ownerGithubTokenCipher` + `deployment.update` (write-only) | +| R3-runaway | P1 | Runaway tool loops | Default 48 tool fuse → `need-user` ask; 15m stall ask | +| R3-ux | P1 | Narration flood / stream fragments | Activity line + streaming text merge in `progressMessageText` | +| R3-i18n | P2 | English run chrome in DE | Lingui for awareness strip + working narration | +| R3-name | P2 | Duplicate “Chief” in project sidebar | Project main Oo defaults to **Lead** | +| R3-flake | P1 | `evo-deploy.test.ts` grep flake | Fixed `--filter` pattern in stack fixture | + +**Evo ops:** Rebuild `rakazo/computer:local` on Evo after merge (`pnpm sandbox:build` or compose `computer-image` target). Server deploy via `ops/evo-deploy/deploy.sh` updates app images; the computer image is separate—rebuild and restart sandboxes so new containers pick up git tools. + +**Security (GitHub token):** Token is stored only as ciphertext in `deployment_settings.ownerGithubTokenCipher`, set by the deployment owner through `deployment.update`. It is injected as `GITHUB_TOKEN`/`GH_TOKEN` only for the owner’s runs in non-default (project) spaces—not baked into the image or repo. Push/PR policy is enforced in the shell tool layer regardless of token presence. + ### Remaining — Phase 3+ | ID | Sev | Issue | Notes | diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 9e256bc..69f5f8e 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -29,6 +29,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ procps \ bash \ + git \ + gh \ + openssh-client \ + curl \ + sudo \ chromium \ fonts-liberation \ fonts-dejavu-core \ @@ -37,6 +42,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 1000 rakazo \ && useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash rakazo \ + && usermod -aG sudo rakazo \ + && printf '%s\n' 'rakazo ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/rakazo \ + && chmod 440 /etc/sudoers.d/rakazo \ && mkdir -p /etc/rakazo/fluxbox \ && printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \ && chmod +x /usr/bin/fbsetbg \ diff --git a/infra/sandboxes/computer/computer-image-tools.test.sh b/infra/sandboxes/computer/computer-image-tools.test.sh new file mode 100755 index 0000000..7501310 --- /dev/null +++ b/infra/sandboxes/computer/computer-image-tools.test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${RAKAZO_COMPUTER_IMAGE:-rakazo/computer:local}" + +for tool in git gh ssh curl sudo; do + docker run --rm "$IMAGE" bash -lc "command -v $tool >/dev/null" +done + +docker run --rm "$IMAGE" bash -lc 'git --version | grep -q "^git version "' +docker run --rm "$IMAGE" bash -lc 'gh --version | grep -q "^gh version "' diff --git a/infra/sandboxes/supervisor/src/computer-spec.test.ts b/infra/sandboxes/supervisor/src/computer-spec.test.ts index 8b59175..e02be97 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.test.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.test.ts @@ -65,6 +65,28 @@ describe("graphical computer spec", () => { expect(() => resolveSpaceComputerLimit(value)).toThrow(/positive integer/); }); + it("binds a linked project folder at /home/rakazo/project", () => { + const options = containerCreateOptions({ + name: "rakazo-bot-abc", + image: COMPUTER_IMAGE, + botId: "abc", + spaceId: "ws", + homePath: "/var/rakazo/homes/abc", + projectWorkspacePath: "/var/rakazo/projects/demo", + networkMode: "rakazo_default", + }); + const mounts = options.HostConfig.Mounts ?? []; + expect(mounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Type: "bind", + Source: "/var/rakazo/projects/demo", + Target: "/home/rakazo/project", + }), + ]), + ); + }); + it("creates a VNC desktop, not an alpine sleep fallback", () => { const options = containerCreateOptions({ name: "rakazo-bot-abc", diff --git a/infra/sandboxes/supervisor/src/computer-spec.ts b/infra/sandboxes/supervisor/src/computer-spec.ts index 8e70f9b..44a6522 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { statSync } from "node:fs"; import path from "node:path"; import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime"; import type Docker from "dockerode"; @@ -214,6 +215,21 @@ export function homeVolumeMatches( ); } +/** Validates a host folder before bind-mounting it into a project computer. */ +export function assertProjectWorkspaceBindPath(hostPath: string): string { + const resolved = path.resolve(hostPath); + let stat: ReturnType; + try { + stat = statSync(resolved); + } catch { + throw new Error("Project workspace path does not exist on the host."); + } + if (!stat.isDirectory()) { + throw new Error("Project workspace path must be a directory on the host."); + } + return resolved; +} + export interface ComputerCreateInput { name: string; image: string; @@ -221,6 +237,8 @@ export interface ComputerCreateInput { spaceId: string; homePath: string; homeVolume?: { name: string; subpath: string }; + /** Host path to bind read-write at /home/rakazo/project (deployment owner project spaces). */ + projectWorkspacePath?: string; user?: string; controlToken?: string; networkMode?: string; @@ -240,8 +258,33 @@ export type SandboxInput = | PointerInput | { kind: "clipboard"; text: string }; +function computerVolumeMounts(input: ComputerCreateInput): Docker.MountSettings[] { + const mounts: Docker.MountSettings[] = []; + if (input.homeVolume) { + mounts.push({ + Type: "volume", + Source: input.homeVolume.name, + Target: "/home/rakazo", + VolumeOptions: { + NoCopy: true, + Subpath: input.homeVolume.subpath, + } as Docker.MountSettings["VolumeOptions"], + }); + } + if (input.projectWorkspacePath) { + mounts.push({ + Type: "bind", + Source: input.projectWorkspacePath, + Target: "/home/rakazo/project", + ReadOnly: false, + }); + } + return mounts; +} + export function containerCreateOptions(input: ComputerCreateInput) { const ports = computerPortBindings(input.publishControlPort); + const volumeMounts = computerVolumeMounts(input); return { Image: input.image, name: input.name, @@ -262,23 +305,20 @@ export function containerCreateOptions(input: ComputerCreateInput) { }, ExposedPorts: ports.ExposedPorts, HostConfig: { - ...(input.homeVolume + ...(input.homeVolume || input.projectWorkspacePath ? { Binds: undefined, - Mounts: [ - { - Type: "volume" as const, - Source: input.homeVolume.name, - Target: "/home/rakazo", - // Docker makes Labels and DriverConfig optional; dockerode's types do not. - VolumeOptions: { - NoCopy: true, - Subpath: input.homeVolume.subpath, - } as Docker.MountSettings["VolumeOptions"], - }, - ], + Mounts: volumeMounts, } - : { Binds: [`${input.homePath}:/home/rakazo`], Mounts: undefined }), + : { + Binds: [ + `${input.homePath}:/home/rakazo`, + ...(input.projectWorkspacePath + ? [`${input.projectWorkspacePath}:/home/rakazo/project`] + : []), + ], + Mounts: undefined, + }), PortBindings: ports.PortBindings, ShmSize: 256 * 1024 * 1024, CapDrop: ["ALL"], diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index 6d16dc6..28b7979 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; @@ -19,6 +19,7 @@ import { Hono, type MiddlewareHandler } from "hono"; import { bodyLimit } from "hono/body-limit"; import { z } from "zod"; import { + assertProjectWorkspaceBindPath, assertVolumeSubpathSupport, COMPUTER_GID, COMPUTER_IMAGE, @@ -156,6 +157,7 @@ app.post("/computers", async (c) => { botId: z.string().min(1), homePath: z.string().min(1), spaceId: z.string().min(1), + projectWorkspacePath: z.string().min(1).optional(), }) .parse(await c.req.json()); try { @@ -233,6 +235,9 @@ app.post("/computers", async (c) => { ? COMPUTER_GID : hostGid; await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid); + const projectWorkspacePath = body.projectWorkspacePath + ? assertProjectWorkspaceBindPath(body.projectWorkspacePath) + : undefined; const name = containerNameFor(body.botId); const createdNetwork = screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId); @@ -248,6 +253,7 @@ app.post("/computers", async (c) => { botId: body.botId, spaceId: body.spaceId, ...storage, + ...(projectWorkspacePath ? { projectWorkspacePath } : {}), user: computerUser, networkMode, controlToken: randomUUID(), diff --git a/packages/adapter-kit/src/interfaces.ts b/packages/adapter-kit/src/interfaces.ts index 3968d14..a8315e8 100644 --- a/packages/adapter-kit/src/interfaces.ts +++ b/packages/adapter-kit/src/interfaces.ts @@ -94,6 +94,7 @@ export interface SandboxProvider { request: { botId: string; homePath: string; + projectWorkspacePath?: string; providerRef?: string; providerKind?: ComputerRef["kind"]; }, diff --git a/packages/adapters/src/computer-lifecycle.ts b/packages/adapters/src/computer-lifecycle.ts index d236fbb..f51dbd7 100644 --- a/packages/adapters/src/computer-lifecycle.ts +++ b/packages/adapters/src/computer-lifecycle.ts @@ -20,6 +20,7 @@ import { hasActiveComputerControl, } from "./computer-control.js"; import { toComputerRef } from "./computer-support.js"; +import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js"; import { checkpointComputerWorkspace, ensureComputerWorkspaceLayout, @@ -280,10 +281,20 @@ export async function provisionComputer( let provisioned: ComputerRef | undefined; try { await onProgress?.("recreating"); + let projectWorkspacePath: string | undefined; + try { + projectWorkspacePath = await resolveProjectWorkspaceBind(deps.prisma, { + spaceId: context.spaceId, + userId: context.userId, + }); + } catch { + projectWorkspacePath = undefined; + } const ref = await deps.sandbox.provision( { botId: existing.homeKey, homePath, + projectWorkspacePath, providerRef: existing.providerRef ?? undefined, providerKind: existing.kind as ComputerRef["kind"], }, diff --git a/packages/adapters/src/computer-support.test.ts b/packages/adapters/src/computer-support.test.ts index 4d96ee2..47378f9 100644 --- a/packages/adapters/src/computer-support.test.ts +++ b/packages/adapters/src/computer-support.test.ts @@ -31,6 +31,12 @@ describe("Team Computer bot folders", () => { ); expect(resolveBotWorkspacePath("team", "bot-1", "/root-note.md")).toBe("root-note.md"); expect(resolveBotWorkspacePath("team", "bot-1", "/shared/brief.md")).toBe("shared/brief.md"); + expect(resolveBotWorkspacePath("team", "bot-1", "/home/rakazo/notes/x.md")).toBe( + "notes/x.md", + ); + expect(resolveBotWorkspacePath("team", "bot-1", "/home/rakazo/project/src/main.ts")).toBe( + "project/src/main.ts", + ); }); it("presents the current bot folder as its home without hiding Team paths", () => { diff --git a/packages/adapters/src/computer-support.ts b/packages/adapters/src/computer-support.ts index 00948e0..16be115 100644 --- a/packages/adapters/src/computer-support.ts +++ b/packages/adapters/src/computer-support.ts @@ -239,9 +239,15 @@ function isTeamRootPath(value: string): boolean { ); } -function stripVirtualWorkspaceRoot(value: string): string | null { +const VIRTUAL_COMPUTER_HOME = "/home/rakazo"; + +export function stripVirtualWorkspaceRoot(value: string): string | null { const portable = value.replace(/\\/g, "/"); if (portable === "/") return ""; + if (portable === VIRTUAL_COMPUTER_HOME || portable === `${VIRTUAL_COMPUTER_HOME}/`) return ""; + if (portable.startsWith(`${VIRTUAL_COMPUTER_HOME}/`)) { + return portable.slice(`${VIRTUAL_COMPUTER_HOME}/`.length); + } return portable.startsWith("/") ? portable.slice(1) : null; } diff --git a/packages/adapters/src/deployment-github-token.test.ts b/packages/adapters/src/deployment-github-token.test.ts new file mode 100644 index 0000000..0dce93e --- /dev/null +++ b/packages/adapters/src/deployment-github-token.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; +import { ownerGithubEnvironmentForRun } from "./deployment-github-token.js"; + +describe("ownerGithubEnvironmentForRun", () => { + it("returns tokens only for the deployment owner in a project space", () => { + const secrets = { load: vi.fn(() => "ghp_test") }; + expect( + ownerGithubEnvironmentForRun({ + settings: { ownerUserId: "owner-1", ownerGithubTokenCipher: "cipher" }, + userId: "owner-1", + spaceIsDefault: false, + secrets, + }), + ).toEqual({ GITHUB_TOKEN: "ghp_test", GH_TOKEN: "ghp_test" }); + expect( + ownerGithubEnvironmentForRun({ + settings: { ownerUserId: "owner-1", ownerGithubTokenCipher: "cipher" }, + userId: "member-2", + spaceIsDefault: false, + secrets, + }), + ).toEqual({}); + }); +}); diff --git a/packages/adapters/src/deployment-github-token.ts b/packages/adapters/src/deployment-github-token.ts new file mode 100644 index 0000000..cc9278b --- /dev/null +++ b/packages/adapters/src/deployment-github-token.ts @@ -0,0 +1,23 @@ +import type { DeploymentSettings } from "@rakazo/db"; + +type SecretLoader = { + load(ciphertext: string, recordId: string): string; +}; + +/** Injects the deployment owner's GitHub token into project computer shells only. */ +export function ownerGithubEnvironmentForRun(input: { + settings: Pick | null; + userId: string; + spaceIsDefault: boolean; + secrets: SecretLoader; +}): Record { + if (input.spaceIsDefault) return {}; + if (!input.settings?.ownerGithubTokenCipher) return {}; + if (input.settings.ownerUserId !== input.userId) return {}; + const token = input.secrets.load( + input.settings.ownerGithubTokenCipher, + "deployment-github-token", + ); + if (!token.trim()) return {}; + return { GITHUB_TOKEN: token, GH_TOKEN: token }; +} diff --git a/packages/adapters/src/docker-sandbox.ts b/packages/adapters/src/docker-sandbox.ts index 5300bf6..a4bb687 100644 --- a/packages/adapters/src/docker-sandbox.ts +++ b/packages/adapters/src/docker-sandbox.ts @@ -134,7 +134,7 @@ export class DockerSandboxProvider implements SandboxProvider { } async provision( - request: { botId: string; homePath: string }, + request: { botId: string; homePath: string; projectWorkspacePath?: string }, context: AdapterContext, ): Promise { const res = await fetch(this.url("/computers"), { @@ -144,6 +144,9 @@ export class DockerSandboxProvider implements SandboxProvider { botId: request.botId, homePath: request.homePath, spaceId: context.spaceId, + ...(request.projectWorkspacePath + ? { projectWorkspacePath: request.projectWorkspacePath } + : {}), }), signal: context.signal, }); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 43bc0da..40f6ac2 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -125,6 +125,9 @@ import { formatAgentEnvironmentInstruction, redactAgentCommandResult, } from "./agent-environment.js"; +import { ownerGithubEnvironmentForRun } from "./deployment-github-token.js"; +import { evaluateGitShellPolicy } from "./git-shell-policy.js"; +import { runStallWithoutProgressMs } from "./pi-model-config.js"; import { buildApprovalAskBlock } from "./approval-ask.js"; import { approvalPausedToolResult, @@ -452,6 +455,7 @@ const BUILTIN_AGENT_TOOL_NAMES = new Set(builtinAgentTools.map((tool) => tool.na /** Cap the roster so a large Space cannot flood the prompt. */ const BOT_DIRECTORY_LIMIT = 40; const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots."; +const gitPushApprovedRuns = new Set(); /** Runs an accepted suggestion's stored call; it never calls a model. */ const SUGGESTION_RUNTIME = new ScriptedAgentRuntime(); @@ -1164,6 +1168,7 @@ export function createRunExecutor(deps: ExecutorDeps) { storedConnections, defaultCredential, settings, + spaceRecord, configuredMemory, savedSkills, agentSkills, @@ -1192,7 +1197,20 @@ export function createRunExecutor(deps: ExecutorDeps) { }, }), findDefaultModelCredential(deps.prisma, run), - deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), + deps.prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { + ownerUserId: true, + ownerGithubTokenCipher: true, + deploymentModelCredentialCipher: true, + defaultModelProvider: true, + defaultModelId: true, + }, + }), + deps.prisma.space.findUnique({ + where: { id: run.spaceId }, + select: { isDefault: true, gitDefaultBranch: true }, + }), deps.memoryProviders.resolve({ spaceId: run.spaceId, userId: run.userId }), deps.prisma.taughtSkill.findMany({ where: { botId: run.botId, spaceId: run.spaceId, status: "saved" }, @@ -1210,7 +1228,15 @@ export function createRunExecutor(deps: ExecutorDeps) { }), loadUserContext(deps.prisma, { userId: run.userId, spaceId: run.spaceId }), ]); - const agentEnvironment = decryptAgentEnvironment(agentSecretRows, deps.secretStore); + const agentEnvironment = { + ...decryptAgentEnvironment(agentSecretRows, deps.secretStore), + ...ownerGithubEnvironmentForRun({ + settings, + userId: run.userId, + spaceIsDefault: spaceRecord?.isDefault ?? true, + secrets: deps.secretStore, + }), + }; runSecrets.push(...Object.values(agentEnvironment)); const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment); const hasModelOverride = Boolean(bot.modelProvider && bot.modelId); @@ -1641,8 +1667,8 @@ export function createRunExecutor(deps: ExecutorDeps) { }); const workspaceInstruction = computerMode === "team" - ? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/.` - : "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root."; + ? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/. Absolute paths under /home/rakazo/ map to the same locations as project/, shared/, and bots/. Before appending to a file, read it first and skip content that is already present; prefer the edit tool for surgical changes. Git push and opening pull requests require the user's approval once per run; never force-push or push to the default branch.` + : "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root. Before appending to a file, read it first and skip content that is already present; prefer the edit tool for surgical changes."; let assembled = ""; let currentTextSegment = ""; @@ -2002,10 +2028,25 @@ export function createRunExecutor(deps: ExecutorDeps) { name, viaConnector, ); + const shellCommand = name === "shell" ? String(args.command ?? args.cmd ?? "") : ""; + const gitShellPolicy = + name === "shell" + ? evaluateGitShellPolicy({ + command: shellCommand, + defaultBranch: spaceRecord?.gitDefaultBranch, + gitPushApprovedThisRun: gitPushApprovedRuns.has(runId), + }) + : { action: "allow" as const }; + if (gitShellPolicy.action === "block") { + return { error: gitShellPolicy.reason }; + } + const gitShellNeedsApproval = gitShellPolicy.action === "require_approval"; const requiresApprovalByDefault = - requiresUnattendedApproval || toolRequiresApproval(name, viaConnector); + requiresUnattendedApproval || + toolRequiresApproval(name, viaConnector) || + gitShellNeedsApproval; const requiresMandatoryApproval = - requiresUnattendedApproval || toolRequiresExplicitApproval(name); + requiresUnattendedApproval || toolRequiresExplicitApproval(name) || gitShellNeedsApproval; const connectorKind = connectorKindFromToolName( name, connectedPlugins.map((plugin) => plugin.provider), @@ -2040,8 +2081,14 @@ export function createRunExecutor(deps: ExecutorDeps) { autoReviewEnabled: autoReviewPref, checkerConfigured, }); - let reviewReason: string | undefined; + let reviewReason: string | undefined = + gitShellNeedsApproval && typeof gitShellPolicy === "object" && "reason" in gitShellPolicy + ? gitShellPolicy.reason + : undefined; let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow"; + if (gitShellNeedsApproval && nextApprovedTool) { + gitPushApprovedRuns.add(runId); + } const needsApprovalEarly = plan === "ask" || plan === "judge"; // A resumed approval keeps its key even if "Always allow" changed the policy. const usesApprovalKey = @@ -4264,6 +4311,51 @@ export function createRunExecutor(deps: ExecutorDeps) { }, context, ); + const runStallMs = runStallWithoutProgressMs(); + let lastUserVisibleProgressAt = Date.now(); + const touchUserVisibleProgress = () => { + lastUserVisibleProgressAt = Date.now(); + }; + const pauseForRunStall = async (): Promise => { + if (runStallMs <= 0 || scripted || approvalPausePending) return false; + if (Date.now() - lastUserVisibleProgressAt < runStallMs) return false; + if (!(await renewRunLease(deps, runId, workerId, fence))) return true; + await workspaceCheckpoint.flush(); + const stalledText = `This run has been working for ${Math.round(runStallMs / 60_000)} minutes without a visible update. Choose whether to keep going or stop for now.`; + const paused = await deps.events.pauseRunForInput({ + spaceId: run.spaceId, + threadId: run.threadId, + botId: run.botId, + runId, + attemptId: attempt.id, + leaseOwner: workerId, + leaseFence: fence, + blocks: [ + { + kind: "ask", + text: stalledText, + status: "pending", + actions: [ + { id: "continue-run", label: "Keep going" }, + { id: "stop-run", label: "Stop for now" }, + ], + }, + ], + offeredActions: [ + { id: "continue-run", label: "Keep going" }, + { id: "stop-run", label: "Stop for now" }, + ], + }); + if (!paused) return true; + await notifyRun(deps, run, { + kind: "help", + title: `${bot.name} needs your call`, + body: stalledText, + botId: bot.id, + threadId: thread.id, + }); + return true; + }; for await (const event of withRuntimeCleanup(runtimeEvents, runAbortController)) { if (approvalPausePending) return; if (!leaseValid) return; @@ -4272,8 +4364,10 @@ export function createRunExecutor(deps: ExecutorDeps) { lastLeaseCheckAt = now; if (!(await verifyLeaseOwnership())) return; } + if (await pauseForRunStall()) return; if (event.type === "text") { + touchUserVisibleProgress(); assembled += event.text; currentTextSegment += event.text; toolCallStreak = { key: undefined, count: 0 }; @@ -4284,6 +4378,7 @@ export function createRunExecutor(deps: ExecutorDeps) { await flushProgress(); } } else if (event.type === "progress") { + touchUserVisibleProgress(); toolCallStreak = { key: undefined, count: 0 }; // Flush batched text deltas first so an activity line cannot land // ahead of text the model streamed before the tool call. @@ -4403,13 +4498,34 @@ export function createRunExecutor(deps: ExecutorDeps) { }); return; } else if (event.type === "tool") { + touchUserVisibleProgress(); // Preserve event ordering when the throttle still holds recent narration: the // client must see that text before the tool call it describes. await flushProgress(); // Promote streamed narration into a durable, replyable chat message before // tools continue, so long turns do not look stalled and stay replyable. if (event.name !== "message_user") { - await publishMidTurnNarration(); + const pendingNarration = extractNarrationText(messageSegments, currentTextSegment); + if ( + pendingNarration.text.trim().length >= 120 && + endsSentence(pendingNarration.text) + ) { + await publishMidTurnNarration(); + } + } + if (event.name === "shell") { + const command = String(event.args?.command ?? event.args?.cmd ?? "").trim(); + const label = command + ? `Running: ${command.length > 120 ? `${command.slice(0, 119)}…` : command}` + : `Using ${humanizeToolName(event.name)}…`; + await deps.events.append({ + spaceId: run.spaceId, + threadId: thread.id, + botId: bot.id, + type: "thread.progress", + runId, + payload: { text: redactSecrets(label, runSecrets), activity: true }, + }); } await deps.events.append({ spaceId: run.spaceId, diff --git a/packages/adapters/src/git-shell-policy.test.ts b/packages/adapters/src/git-shell-policy.test.ts new file mode 100644 index 0000000..db21b2a --- /dev/null +++ b/packages/adapters/src/git-shell-policy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { evaluateGitShellPolicy, parseGitPushRef } from "./git-shell-policy.js"; + +describe("git shell policy", () => { + it("blocks force push", () => { + expect( + evaluateGitShellPolicy({ + command: "git push --force origin feature", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toMatchObject({ action: "block" }); + }); + + it("blocks push to the default branch", () => { + expect( + evaluateGitShellPolicy({ + command: "git push origin main", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toMatchObject({ action: "block" }); + }); + + it("requires approval before the first push of a run", () => { + expect( + evaluateGitShellPolicy({ + command: "git push -u origin feature/login", + defaultBranch: "main", + gitPushApprovedThisRun: false, + }), + ).toMatchObject({ action: "require_approval" }); + }); + + it("allows feature branch push after approval", () => { + expect( + evaluateGitShellPolicy({ + command: "git push -u origin feature/login", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toEqual({ action: "allow" }); + }); + + it("parses refspec destinations", () => { + expect(parseGitPushRef("git push origin HEAD:feature/x")).toBe("feature/x"); + }); +}); diff --git a/packages/adapters/src/git-shell-policy.ts b/packages/adapters/src/git-shell-policy.ts new file mode 100644 index 0000000..415e572 --- /dev/null +++ b/packages/adapters/src/git-shell-policy.ts @@ -0,0 +1,81 @@ +export type GitShellPolicyInput = { + command: string; + defaultBranch: string | null | undefined; + gitPushApprovedThisRun: boolean; +}; + +export type GitShellPolicyDecision = + | { action: "allow" } + | { action: "block"; reason: string } + | { action: "require_approval"; reason: string }; + +const GIT_PUSH_RE = /\bgit\b[^\n;&|]*\bpush\b/i; +const GIT_FORCE_PUSH_RE = /(?:--force-with-lease|--force(?:\s|$)|(?:^|\s)-f(?:\s|$))/i; +const GH_PR_RE = /\bgh\b(?:(?:\s+[^;&|]+)*\s+pr\b|\s+pr\b)(?:\s+create\b|\s|$|[;&|])/i; + +export function evaluateGitShellPolicy(input: GitShellPolicyInput): GitShellPolicyDecision { + const command = input.command.trim(); + if (!command) return { action: "allow" }; + + if (GIT_PUSH_RE.test(command)) { + if (GIT_FORCE_PUSH_RE.test(command)) { + return { + action: "block", + reason: "Force-push is not allowed. Push to a new branch without force flags.", + }; + } + const targetBranch = parseGitPushRef(command); + const protectedBranch = normalizeBranchName(input.defaultBranch); + if (protectedBranch && targetBranch && targetBranch === protectedBranch) { + return { + action: "block", + reason: `Push to the default branch (${protectedBranch}) is not allowed. Create a feature branch and push that instead.`, + }; + } + if (!input.gitPushApprovedThisRun) { + return { + action: "require_approval", + reason: + "This run would push commits to a remote. Approve once to allow git push in this run.", + }; + } + } + + if (GH_PR_RE.test(command) && !input.gitPushApprovedThisRun) { + return { + action: "require_approval", + reason: + "Opening a pull request is a consequential git action. Approve once to allow gh pr in this run.", + }; + } + + return { action: "allow" }; +} + +function normalizeBranchName(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed || trimmed === "HEAD") return null; + return trimmed.replace(/^origin\//, ""); +} + +/** Best-effort parse of the ref git push would update on the remote. */ +export function parseGitPushRef(command: string): string | null { + const tokens = command + .replace(/&&/g, ";") + .replace(/\|\|/g, ";") + .split(/[;&|]/) + .flatMap((segment) => segment.trim().split(/\s+/)) + .filter(Boolean); + const pushIndex = tokens.indexOf("push"); + if (pushIndex < 0 || !tokens.slice(0, pushIndex).includes("git")) return null; + const args = tokens.slice(pushIndex + 1).filter((token) => !token.startsWith("-")); + if (args.length === 0) return null; + const last = args.at(-1); + if (!last) return null; + if (last.includes(":")) { + const [, dest] = last.split(":"); + return normalizeBranchName(dest) ?? null; + } + if (args.length >= 2) return normalizeBranchName(last); + return normalizeBranchName(last); +} diff --git a/packages/adapters/src/pi-model-config.ts b/packages/adapters/src/pi-model-config.ts index 76edf06..cf4e2cc 100644 --- a/packages/adapters/src/pi-model-config.ts +++ b/packages/adapters/src/pi-model-config.ts @@ -69,7 +69,7 @@ const toolCallBudgetsByRun = new Map(); /** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */ export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number { const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim(); - if (!raw) return 0; + if (!raw) return 48; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return 0; return Math.floor(parsed); @@ -80,6 +80,10 @@ export function toolCallBudgetFor(runId: string): ToolCallBudget { if (existing) { existing.inFlight = 0; existing.limit = maxToolCallsPerTurn(); + if (existing.exceeded) { + existing.exceeded = false; + existing.count = 0; + } return existing; } const budget: ToolCallBudget = { @@ -141,7 +145,16 @@ export function endToolCall(host: { toolCallBudget: ToolCallBudget; abortTurn(): } export function toolCallBudgetExceededMessage(limit: number): string { - return `I stopped after reaching the limit of ${limit} tool calls in this turn. Send another message to continue.`; + return `I stopped after reaching the limit of ${limit} tool calls in this turn. Choose whether to continue with more tool calls or stop for now.`; +} + +/** Pause a long run when no user-visible progress landed for this many milliseconds. */ +export function runStallWithoutProgressMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.RUN_STALL_WITHOUT_PROGRESS_MS?.trim(); + if (!raw) return 15 * 60_000; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + return Math.floor(parsed); } // --- Model resolution --- diff --git a/packages/adapters/src/pi-runtime-tool-dispatch.test.ts b/packages/adapters/src/pi-runtime-tool-dispatch.test.ts index 160f89d..7019c95 100644 --- a/packages/adapters/src/pi-runtime-tool-dispatch.test.ts +++ b/packages/adapters/src/pi-runtime-tool-dispatch.test.ts @@ -872,7 +872,8 @@ describe("Pi connector tool dispatch", () => { expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." }); }); - it("allows more than 80 tool calls by default when no fuse is configured", async () => { + it("allows more than 80 tool calls when MAX_TOOL_CALLS_PER_TURN is unlimited", async () => { + process.env.MAX_TOOL_CALLS_PER_TURN = "0"; fakeAgentState.mode = "parent-limit"; const executeTool = vi.fn(async () => ({ ok: true })); const runtime = new PiAgentRuntime(); @@ -909,6 +910,7 @@ describe("Pi connector tool dispatch", () => { text: expect.stringContaining("tool calls in one turn"), }), ); + delete process.env.MAX_TOOL_CALLS_PER_TURN; }); it("soft-stops with a final message when MAX_TOOL_CALLS_PER_TURN is set", async () => { @@ -948,13 +950,14 @@ describe("Pi connector tool dispatch", () => { text: "Stopped: more than 80 tool calls in one turn.", }); expect(events).toContainEqual({ - type: "text", - text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", - }); - expect(events.at(-1)).toEqual({ - type: "done", - text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", + type: "ask", + text: "I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.", + actions: [ + { id: "continue-tools", label: "Continue with more tool calls" }, + { id: "stop-tools", label: "Stop for now" }, + ], }); + expect(events.at(-1)).toEqual({ type: "done" }); }); it("shares an optional tool-call fuse with subagents and still emits a final message", async () => { @@ -1001,13 +1004,14 @@ describe("Pi connector tool dispatch", () => { text: "Stopped: more than 80 tool calls in one turn.", }); expect(events).toContainEqual({ - type: "text", - text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", - }); - expect(events).toContainEqual({ - type: "done", - text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", + type: "ask", + text: "I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.", + actions: [ + { id: "continue-tools", label: "Continue with more tool calls" }, + { id: "stop-tools", label: "Stop for now" }, + ], }); + expect(events).toContainEqual({ type: "done" }); const subagentEvents = events.filter( (event): event is { type: "subagent"; status: string; result?: string } => typeof event === "object" && @@ -1021,14 +1025,14 @@ describe("Pi connector tool dispatch", () => { type: "subagent", status: "completed", result: - "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", + "I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.", }), ); }); it("treats unset, empty, and non-positive MAX_TOOL_CALLS_PER_TURN as unlimited", () => { - expect(maxToolCallsPerTurn({})).toBe(0); - expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(0); + expect(maxToolCallsPerTurn({})).toBe(48); + expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(48); expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "0" })).toBe(0); expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0); expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0); diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index 170d6eb..849a824 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -376,15 +376,16 @@ export class PiAgentRuntime implements AgentRuntime { throw new Error(sanitizeProviderError(model.provider, error)); } if (budgetExceeded) { + host.pausePending = true; const budgetMessage = toolCallBudgetExceededMessage(host.toolCallBudget.limit); - if (streamed.trim()) { - const suffix = `\n\n${budgetMessage}`; - queue.push({ type: "text", text: suffix }); - streamed += suffix; - } else { - queue.push({ type: "text", text: budgetMessage }); - streamed = budgetMessage; - } + queue.push({ + type: "ask", + text: budgetMessage, + actions: [ + { id: "continue-tools", label: "Continue with more tool calls" }, + { id: "stop-tools", label: "Stop for now" }, + ], + }); } else if (!host.pausePending && toolWorkPendingFinal) { // Discard cumulative pre-tool narration from the terminal payload and make the // missing final response visible to the user instead of silently completing. @@ -413,8 +414,7 @@ export class PiAgentRuntime implements AgentRuntime { } finally { queue.close(); if (trackedBudget) { - const keepForResume = - (signal.aborted || Boolean(resumeHost?.pausePending)) && !trackedBudget.exceeded; + const keepForResume = signal.aborted || Boolean(resumeHost?.pausePending); releaseToolCallBudget(request.runId, keepForResume); } } diff --git a/packages/adapters/src/project-workspace-bind.ts b/packages/adapters/src/project-workspace-bind.ts new file mode 100644 index 0000000..68f5ac0 --- /dev/null +++ b/packages/adapters/src/project-workspace-bind.ts @@ -0,0 +1,26 @@ +import type { PrismaClient } from "@rakazo/db"; +import { resolveProjectWorkspaceDirectory } from "@rakazo/db"; + +/** Resolved host folder to bind at /home/rakazo/project for the deployment owner's project spaces. */ +export async function resolveProjectWorkspaceBind( + prisma: PrismaClient, + input: { spaceId: string; userId: string }, +): Promise { + const [space, settings] = await Promise.all([ + prisma.space.findUnique({ + where: { id: input.spaceId }, + select: { workspacePath: true, isDefault: true }, + }), + prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { ownerUserId: true }, + }), + ]); + if (!space?.workspacePath || space.isDefault) return undefined; + if (settings?.ownerUserId !== input.userId) return undefined; + try { + return await resolveProjectWorkspaceDirectory(space.workspacePath); + } catch { + return undefined; + } +} diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 56f9d4b..f819ed3 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -1163,6 +1163,7 @@ export const DeploymentSettingsSchema = z.object({ signupsEnabled: z.boolean(), signupAllowlist: z.array(z.string()), hasDeploymentModelCredential: z.boolean(), + hasOwnerGithubToken: z.boolean(), defaultProvider: z.string().nullable(), defaultModel: z.string().nullable(), computerHost: z.enum(["docker", "this-mac"]).nullable(), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8b1ad34..a1e6799 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -273,6 +273,8 @@ export const appContract = { signupsEnabled: z.boolean().optional(), signupAllowlist: z.array(z.string()).optional(), computerHost: z.enum(["docker", "this-mac"]).nullable().optional(), + /** Plaintext GitHub token for the deployment owner; null clears. Never returned on read. */ + ownerGithubToken: z.string().min(1).max(400).nullable().optional(), }), ) .output(DeploymentSettingsSchema), diff --git a/packages/core/src/events.test.ts b/packages/core/src/events.test.ts index 2c5c0e0..904ab63 100644 --- a/packages/core/src/events.test.ts +++ b/packages/core/src/events.test.ts @@ -8,6 +8,7 @@ import { endsSentence, humanizeToolName, isRunTerminalEvent, + progressMessageText, projectMessages, reduceLiveMessageBlocks, runFailureError, @@ -37,6 +38,22 @@ describe("isRunTerminalEvent", () => { }); }); +describe("progressMessageText", () => { + it("appends streaming text chunks instead of replacing prior progress", () => { + const first = progressMessageText({ text: "se korrekt ", streaming: true }); + expect(first).toBe("se korrekt "); + expect(progressMessageText({ text: "dekodieren:", streaming: true }, first)).toBe( + "se korrekt dekodieren:", + ); + }); + + it("keeps activity lines as replacements", () => { + expect( + progressMessageText({ text: "Running: git status", activity: true }, "old narration"), + ).toBe("Running: git status"); + }); +}); + describe("reduceLiveMessageBlocks", () => { it("preserves structured live activity markers", () => { expect( diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index ade7cd3..632bc2b 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -307,9 +307,15 @@ export function progressMessageText( payload: Record | undefined, previousText = "", ): string { - return typeof payload?.delta === "string" - ? previousText + payload.delta - : String(payload?.text ?? ""); + if (typeof payload?.delta === "string") return previousText + payload.delta; + const text = payload?.text; + if (typeof text !== "string") return previousText; + if (payload?.activity === true) return text; + if (payload?.streaming === true && previousText) { + if (text.startsWith(previousText)) return text; + return previousText + text; + } + return text; } export function subagentBlockFromPayload( diff --git a/packages/core/src/git-checkout.test.ts b/packages/core/src/git-checkout.test.ts index a5fd992..67e7316 100644 --- a/packages/core/src/git-checkout.test.ts +++ b/packages/core/src/git-checkout.test.ts @@ -41,7 +41,10 @@ describe("readGitCheckout", () => { return { ok: true, output: "abc123def456\n" }; } if (args.join(" ") === "rev-parse --abbrev-ref HEAD") { - return { ok: true, output: "main\n" }; + return { ok: true, output: "feature/login\n" }; + } + if (args.join(" ") === "symbolic-ref --short refs/remotes/origin/HEAD") { + return { ok: true, output: "origin/main\n" }; } if (args.join(" ") === "remote get-url origin") { return { ok: true, output: "https://github.com/example/demo.git\n" }; diff --git a/packages/core/src/git-checkout.ts b/packages/core/src/git-checkout.ts index 210f4e3..7d7822f 100644 --- a/packages/core/src/git-checkout.ts +++ b/packages/core/src/git-checkout.ts @@ -62,14 +62,20 @@ export async function readGitCheckout( }; } - const [head, branch, remote, status, untracked] = await Promise.all([ + const [head, branch, remoteDefault, remote, status, untracked] = await Promise.all([ runGit(["rev-parse", "HEAD"], root), runGit(["rev-parse", "--abbrev-ref", "HEAD"], root), + runGit(["symbolic-ref", "--short", `refs/remotes/${DEFAULT_UPDATE_REMOTE}/HEAD`], root), runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root), runGit(["--no-optional-locks", ...gitStatusArgv()], root), runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root), ]); + const remoteDefaultBranch = remoteDefault.ok + ? remoteDefault.output.trim().replace(/^origin\//, "") || null + : null; + const checkedOutBranch = branch.ok ? branch.output.trim() || null : null; + const porcelain = parseGitStatusPorcelain(status.ok ? status.output : ""); const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : ""); const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])]; @@ -78,7 +84,7 @@ export async function readGitCheckout( return { present: true, remoteUrl: remote.ok ? remote.output.trim() || null : null, - defaultBranch: branch.ok ? branch.output.trim() || null : null, + defaultBranch: remoteDefaultBranch ?? checkedOutBranch, headCommit: head.ok ? head.output.trim() || null : null, dirty: stateReadable ? dirtyPaths.length > 0 : true, dirtyPaths: stateReadable ? dirtyPaths : [], diff --git a/packages/db/prisma/migrations/20261007120000_deployment_owner_github_token/migration.sql b/packages/db/prisma/migrations/20261007120000_deployment_owner_github_token/migration.sql new file mode 100644 index 0000000..6a72ecf --- /dev/null +++ b/packages/db/prisma/migrations/20261007120000_deployment_owner_github_token/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "deployment_settings" ADD COLUMN IF NOT EXISTS "ownerGithubTokenCipher" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 2ba907b..74b4a7f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -284,6 +284,7 @@ model DeploymentSettings { defaultModelProvider String? defaultModelId String? deploymentModelCredentialCipher String? + ownerGithubTokenCipher String? computerHost String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts index 2f7d9ab..eb4c819 100644 --- a/packages/db/src/main-agent.ts +++ b/packages/db/src/main-agent.ts @@ -18,7 +18,7 @@ export const MAIN_BOT_DEFAULTS = { /** Name and role of a project's main bot, the one the user talks to about that project. */ export const PROJECT_MAIN_BOT_DEFAULTS = { - name: "Chief", + name: "Lead", title: "Project lead", description: "Leads this project and hands its work to specialists.", } as const; diff --git a/packages/db/src/project-workspace-mount.test.ts b/packages/db/src/project-workspace-mount.test.ts new file mode 100644 index 0000000..d5d2179 --- /dev/null +++ b/packages/db/src/project-workspace-mount.test.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js"; + +describe("assertProjectWorkspaceMountAllowed", () => { + it("rejects the filesystem root and the user home directory", () => { + expect(() => assertProjectWorkspaceMountAllowed(path.parse("/").root)).toThrow(/root/); + expect(() => assertProjectWorkspaceMountAllowed(homedir())).toThrow(/home directory/); + }); +}); diff --git a/packages/db/src/project-workspace-mount.ts b/packages/db/src/project-workspace-mount.ts new file mode 100644 index 0000000..0a529fe --- /dev/null +++ b/packages/db/src/project-workspace-mount.ts @@ -0,0 +1,15 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { InvalidProjectWorkspacePathError } from "./project-workspace.js"; + +/** Host folder allowed to bind into a project computer at /home/rakazo/project. */ +export function assertProjectWorkspaceMountAllowed(resolvedDirectory: string): void { + const normalized = path.resolve(resolvedDirectory); + if (normalized === path.parse(normalized).root) { + throw new InvalidProjectWorkspacePathError("The project folder cannot be the filesystem root."); + } + const home = path.resolve(homedir()); + if (normalized === home) { + throw new InvalidProjectWorkspacePathError("The project folder cannot be your home directory."); + } +} diff --git a/packages/db/src/project-workspace.ts b/packages/db/src/project-workspace.ts index fd0c04a..246eff9 100644 --- a/packages/db/src/project-workspace.ts +++ b/packages/db/src/project-workspace.ts @@ -2,6 +2,7 @@ import { realpath, stat } from "node:fs/promises"; import path from "node:path"; import { readGitCheckout } from "@rakazo/core/git-checkout"; import type { PrismaClient } from "./client.js"; +import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js"; export class InvalidProjectWorkspacePathError extends Error { constructor(message: string) { @@ -36,6 +37,7 @@ export async function resolveProjectWorkspaceDirectory(workspacePath: string): P if (!info.isDirectory()) { throw new InvalidProjectWorkspacePathError("That path is not a folder."); } + assertProjectWorkspaceMountAllowed(resolved); return resolved; } diff --git a/packages/testkit/src/computer-image-tools.test.ts b/packages/testkit/src/computer-image-tools.test.ts new file mode 100644 index 0000000..47765ad --- /dev/null +++ b/packages/testkit/src/computer-image-tools.test.ts @@ -0,0 +1,32 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const script = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../../infra/sandboxes/computer/computer-image-tools.test.sh", +); + +function dockerImagePresent(image: string): boolean { + try { + execFileSync("docker", ["image", "inspect", image], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +describe("rakazo/computer image tooling", () => { + it("includes git, gh, ssh, curl, and sudo when the image is built locally", () => { + const image = process.env.RAKAZO_COMPUTER_IMAGE ?? "rakazo/computer:local"; + if (!dockerImagePresent(image)) { + return; + } + execFileSync("bash", [script], { + env: { ...process.env, RAKAZO_COMPUTER_IMAGE: image }, + stdio: "inherit", + }); + expect(true).toBe(true); + }); +}); diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts index c9b9638..b9c00f8 100644 --- a/packages/testkit/src/evo-deploy.test.ts +++ b/packages/testkit/src/evo-deploy.test.ts @@ -376,7 +376,7 @@ if [[ "$*" == "dev --filter=!@rakazo/web" ]]; then exec sleep 30; fi if [[ "$1" == --filter ]]; then # Exit only after the services were started, so both launches are on record. for _ in $(seq 50); do - grep -q -e "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3 + grep -qF -- "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3 sleep 0.1 done exit 4