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 <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-07 14:42:08 +00:00
parent a2c7182ed6
commit c858cb816c
No known key found for this signature in database
40 changed files with 680 additions and 84 deletions

View File

@ -984,6 +984,21 @@ export function createRouter(deps: RouterDeps) {
"This Mac mode is only available when SANDBOX_PROVIDER=docker on a personal local app.", "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({ await deps.prisma.deploymentSettings.upsert({
where: { id: "default" }, where: { id: "default" },
create: { create: {
@ -993,6 +1008,9 @@ export function createRouter(deps: RouterDeps) {
signupAllowlist: (input.signupAllowlist ?? []).join(","), signupAllowlist: (input.signupAllowlist ?? []).join(","),
signupPolicyInitialized: true, signupPolicyInitialized: true,
computerHost: input.computerHost ?? undefined, computerHost: input.computerHost ?? undefined,
...(ownerGithubTokenCipher !== undefined
? { ownerGithubTokenCipher }
: {}),
}, },
update: { update: {
...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }), ...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }),
@ -1001,6 +1019,9 @@ export function createRouter(deps: RouterDeps) {
? {} ? {}
: { signupPolicyInitialized: true }), : { signupPolicyInitialized: true }),
...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }), ...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }),
...(ownerGithubTokenCipher !== undefined
? { ownerGithubTokenCipher }
: {}),
}, },
}); });
return deploymentDto(deps.prisma, deps.env.sandboxProvider); return deploymentDto(deps.prisma, deps.env.sandboxProvider);
@ -5744,6 +5765,7 @@ async function deploymentDto(prisma: PrismaClient, sandboxProvider: string) {
? settings.signupAllowlist.split(",").filter(Boolean) ? settings.signupAllowlist.split(",").filter(Boolean)
: [], : [],
hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher), hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher),
hasOwnerGithubToken: Boolean(settings?.ownerGithubTokenCipher),
defaultProvider: settings?.defaultModelProvider ?? null, defaultProvider: settings?.defaultModelProvider ?? null,
defaultModel: settings?.defaultModelId ?? null, defaultModel: settings?.defaultModelId ?? null,
computerHost: computerHostFor(settings?.computerHost, sandboxProvider), computerHost: computerHostFor(settings?.computerHost, sandboxProvider),

View File

@ -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. // The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", { const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation", name: "Lead, main conversation",
}); });
await expect(projectEntry).toBeVisible({ timeout: 30_000 }); await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);

View File

@ -44,7 +44,7 @@ test("Projects + opens new project dialog instead of group form", async ({ page
); );
await expect( await expect(
projectSpace(projekte, "Customer project").getByRole("button", { projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation", name: "Lead, main conversation",
}), }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0); 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(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect( await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }), projectSpace(projekte, "Temporary").getByRole("button", { name: "Lead, main conversation" }),
).toBeVisible(); ).toBeVisible();
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([ expect(await rpc<Array<{ kind: string }>>(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. // The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect( await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }), projectSpace(projekte, "Side").getByRole("button", { name: "Lead, main conversation" }),
).toBeVisible(); ).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click(); await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();

View File

@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react/macro";
import { BotAvatar } from "@rakazo/ui-web"; import { BotAvatar } from "@rakazo/ui-web";
import { ArrowRightLeft, GitBranch, MessageCircle, Users } from "lucide-react"; import { ArrowRightLeft, GitBranch, MessageCircle, Users } from "lucide-react";
import type { VoiceAwarenessEvent } from "../../lib/bot-persona"; import type { VoiceAwarenessEvent } from "../../lib/bot-persona";
@ -13,6 +14,7 @@ export function VoiceAwarenessStrip({
botColor: string; botColor: string;
botId: string; botId: string;
}) { }) {
const { t } = useLingui();
const Icon = const Icon =
event.kind === "subagent" event.kind === "subagent"
? Users ? Users
@ -24,6 +26,19 @@ export function VoiceAwarenessStrip({
? MessageCircle ? MessageCircle
: Users; : 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 ( return (
<div <div
role="status" role="status"
@ -34,7 +49,7 @@ export function VoiceAwarenessStrip({
<div className="min-w-0 flex-1 text-start"> <div className="min-w-0 flex-1 text-start">
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground"> <div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground">
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" /> <Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
<span>{awarenessKindLabel(event.kind)}</span> <span>{kindLabel}</span>
</div> </div>
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground"> <p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
{label} {label}
@ -43,20 +58,3 @@ export function VoiceAwarenessStrip({
</div> </div>
); );
} }
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";
}
}

View File

@ -1,4 +1,6 @@
import { msg } from "@lingui/core/macro";
import type { ThreadSnapshot } from "@rakazo/contracts"; import type { ThreadSnapshot } from "@rakazo/contracts";
import { i18n } from "./i18n";
import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web"; import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web";
import type { StagePresencePhase } from "../components/stage/StageHero"; import type { StagePresencePhase } from "../components/stage/StageHero";
import type { VoicePhase } from "../components/voice/VoiceStage"; import type { VoicePhase } from "../components/voice/VoiceStage";
@ -462,7 +464,7 @@ function narrateWorking(archetype: PersonaArchetype, name: string): string {
case "engineer": case "engineer":
return `${name} is heads-down on this.`; return `${name} is heads-down on this.`;
default: default:
return `${name} is working on your request.`; return i18n._(msg`${name} is working on your request.`);
} }
} }

View File

@ -93,6 +93,14 @@ msgstr "{0} ist da"
msgid "{0} is working…" msgid "{0} is working…"
msgstr "{0} arbeitet…" 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) #. placeholder {0}: format(size / 1024)
#: src/lib/format-bytes.ts #: src/lib/format-bytes.ts
msgid "{0} KB" msgid "{0} KB"

View File

@ -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-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`** | | 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+ ### Remaining — Phase 3+
| ID | Sev | Issue | Notes | | ID | Sev | Issue | Notes |

View File

@ -29,6 +29,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
procps \ procps \
bash \ bash \
git \
gh \
openssh-client \
curl \
sudo \
chromium \ chromium \
fonts-liberation \ fonts-liberation \
fonts-dejavu-core \ fonts-dejavu-core \
@ -37,6 +42,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 1000 rakazo \ && groupadd --gid 1000 rakazo \
&& useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash 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 \ && mkdir -p /etc/rakazo/fluxbox \
&& printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \ && printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \
&& chmod +x /usr/bin/fbsetbg \ && chmod +x /usr/bin/fbsetbg \

View File

@ -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 "'

View File

@ -65,6 +65,28 @@ describe("graphical computer spec", () => {
expect(() => resolveSpaceComputerLimit(value)).toThrow(/positive integer/); 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", () => { it("creates a VNC desktop, not an alpine sleep fallback", () => {
const options = containerCreateOptions({ const options = containerCreateOptions({
name: "rakazo-bot-abc", name: "rakazo-bot-abc",

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { statSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime"; import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
import type Docker from "dockerode"; 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<typeof statSync>;
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 { export interface ComputerCreateInput {
name: string; name: string;
image: string; image: string;
@ -221,6 +237,8 @@ export interface ComputerCreateInput {
spaceId: string; spaceId: string;
homePath: string; homePath: string;
homeVolume?: { name: string; subpath: string }; homeVolume?: { name: string; subpath: string };
/** Host path to bind read-write at /home/rakazo/project (deployment owner project spaces). */
projectWorkspacePath?: string;
user?: string; user?: string;
controlToken?: string; controlToken?: string;
networkMode?: string; networkMode?: string;
@ -240,8 +258,33 @@ export type SandboxInput =
| PointerInput | PointerInput
| { kind: "clipboard"; text: string }; | { 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) { export function containerCreateOptions(input: ComputerCreateInput) {
const ports = computerPortBindings(input.publishControlPort); const ports = computerPortBindings(input.publishControlPort);
const volumeMounts = computerVolumeMounts(input);
return { return {
Image: input.image, Image: input.image,
name: input.name, name: input.name,
@ -262,23 +305,20 @@ export function containerCreateOptions(input: ComputerCreateInput) {
}, },
ExposedPorts: ports.ExposedPorts, ExposedPorts: ports.ExposedPorts,
HostConfig: { HostConfig: {
...(input.homeVolume ...(input.homeVolume || input.projectWorkspacePath
? { ? {
Binds: undefined, Binds: undefined,
Mounts: [ Mounts: volumeMounts,
{
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"],
},
],
} }
: { Binds: [`${input.homePath}:/home/rakazo`], Mounts: undefined }), : {
Binds: [
`${input.homePath}:/home/rakazo`,
...(input.projectWorkspacePath
? [`${input.projectWorkspacePath}:/home/rakazo/project`]
: []),
],
Mounts: undefined,
}),
PortBindings: ports.PortBindings, PortBindings: ports.PortBindings,
ShmSize: 256 * 1024 * 1024, ShmSize: 256 * 1024 * 1024,
CapDrop: ["ALL"], CapDrop: ["ALL"],

View File

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import http from "node:http"; import http from "node:http";
import path from "node:path"; import path from "node:path";
@ -19,6 +19,7 @@ import { Hono, type MiddlewareHandler } from "hono";
import { bodyLimit } from "hono/body-limit"; import { bodyLimit } from "hono/body-limit";
import { z } from "zod"; import { z } from "zod";
import { import {
assertProjectWorkspaceBindPath,
assertVolumeSubpathSupport, assertVolumeSubpathSupport,
COMPUTER_GID, COMPUTER_GID,
COMPUTER_IMAGE, COMPUTER_IMAGE,
@ -156,6 +157,7 @@ app.post("/computers", async (c) => {
botId: z.string().min(1), botId: z.string().min(1),
homePath: z.string().min(1), homePath: z.string().min(1),
spaceId: z.string().min(1), spaceId: z.string().min(1),
projectWorkspacePath: z.string().min(1).optional(),
}) })
.parse(await c.req.json()); .parse(await c.req.json());
try { try {
@ -233,6 +235,9 @@ app.post("/computers", async (c) => {
? COMPUTER_GID ? COMPUTER_GID
: hostGid; : hostGid;
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid); await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
const projectWorkspacePath = body.projectWorkspacePath
? assertProjectWorkspaceBindPath(body.projectWorkspacePath)
: undefined;
const name = containerNameFor(body.botId); const name = containerNameFor(body.botId);
const createdNetwork = const createdNetwork =
screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId); screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId);
@ -248,6 +253,7 @@ app.post("/computers", async (c) => {
botId: body.botId, botId: body.botId,
spaceId: body.spaceId, spaceId: body.spaceId,
...storage, ...storage,
...(projectWorkspacePath ? { projectWorkspacePath } : {}),
user: computerUser, user: computerUser,
networkMode, networkMode,
controlToken: randomUUID(), controlToken: randomUUID(),

View File

@ -94,6 +94,7 @@ export interface SandboxProvider {
request: { request: {
botId: string; botId: string;
homePath: string; homePath: string;
projectWorkspacePath?: string;
providerRef?: string; providerRef?: string;
providerKind?: ComputerRef["kind"]; providerKind?: ComputerRef["kind"];
}, },

View File

@ -20,6 +20,7 @@ import {
hasActiveComputerControl, hasActiveComputerControl,
} from "./computer-control.js"; } from "./computer-control.js";
import { toComputerRef } from "./computer-support.js"; import { toComputerRef } from "./computer-support.js";
import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js";
import { import {
checkpointComputerWorkspace, checkpointComputerWorkspace,
ensureComputerWorkspaceLayout, ensureComputerWorkspaceLayout,
@ -280,10 +281,20 @@ export async function provisionComputer(
let provisioned: ComputerRef | undefined; let provisioned: ComputerRef | undefined;
try { try {
await onProgress?.("recreating"); 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( const ref = await deps.sandbox.provision(
{ {
botId: existing.homeKey, botId: existing.homeKey,
homePath, homePath,
projectWorkspacePath,
providerRef: existing.providerRef ?? undefined, providerRef: existing.providerRef ?? undefined,
providerKind: existing.kind as ComputerRef["kind"], providerKind: existing.kind as ComputerRef["kind"],
}, },

View File

@ -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", "/root-note.md")).toBe("root-note.md");
expect(resolveBotWorkspacePath("team", "bot-1", "/shared/brief.md")).toBe("shared/brief.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", () => { it("presents the current bot folder as its home without hiding Team paths", () => {

View File

@ -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, "/"); const portable = value.replace(/\\/g, "/");
if (portable === "/") return ""; 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; return portable.startsWith("/") ? portable.slice(1) : null;
} }

View File

@ -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({});
});
});

View File

@ -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<DeploymentSettings, "ownerUserId" | "ownerGithubTokenCipher"> | null;
userId: string;
spaceIsDefault: boolean;
secrets: SecretLoader;
}): Record<string, string> {
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 };
}

View File

@ -134,7 +134,7 @@ export class DockerSandboxProvider implements SandboxProvider {
} }
async provision( async provision(
request: { botId: string; homePath: string }, request: { botId: string; homePath: string; projectWorkspacePath?: string },
context: AdapterContext, context: AdapterContext,
): Promise<ComputerRef> { ): Promise<ComputerRef> {
const res = await fetch(this.url("/computers"), { const res = await fetch(this.url("/computers"), {
@ -144,6 +144,9 @@ export class DockerSandboxProvider implements SandboxProvider {
botId: request.botId, botId: request.botId,
homePath: request.homePath, homePath: request.homePath,
spaceId: context.spaceId, spaceId: context.spaceId,
...(request.projectWorkspacePath
? { projectWorkspacePath: request.projectWorkspacePath }
: {}),
}), }),
signal: context.signal, signal: context.signal,
}); });

View File

@ -125,6 +125,9 @@ import {
formatAgentEnvironmentInstruction, formatAgentEnvironmentInstruction,
redactAgentCommandResult, redactAgentCommandResult,
} from "./agent-environment.js"; } 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 { buildApprovalAskBlock } from "./approval-ask.js";
import { import {
approvalPausedToolResult, 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. */ /** Cap the roster so a large Space cannot flood the prompt. */
const BOT_DIRECTORY_LIMIT = 40; const BOT_DIRECTORY_LIMIT = 40;
const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots."; const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots.";
const gitPushApprovedRuns = new Set<string>();
/** Runs an accepted suggestion's stored call; it never calls a model. */ /** Runs an accepted suggestion's stored call; it never calls a model. */
const SUGGESTION_RUNTIME = new ScriptedAgentRuntime(); const SUGGESTION_RUNTIME = new ScriptedAgentRuntime();
@ -1164,6 +1168,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
storedConnections, storedConnections,
defaultCredential, defaultCredential,
settings, settings,
spaceRecord,
configuredMemory, configuredMemory,
savedSkills, savedSkills,
agentSkills, agentSkills,
@ -1192,7 +1197,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
}, },
}), }),
findDefaultModelCredential(deps.prisma, run), 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.memoryProviders.resolve({ spaceId: run.spaceId, userId: run.userId }),
deps.prisma.taughtSkill.findMany({ deps.prisma.taughtSkill.findMany({
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" }, 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 }), 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)); runSecrets.push(...Object.values(agentEnvironment));
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment); const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment);
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId); const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
@ -1641,8 +1667,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
}); });
const workspaceInstruction = const workspaceInstruction =
computerMode === "team" 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/.` ? `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."; : "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 assembled = "";
let currentTextSegment = ""; let currentTextSegment = "";
@ -2002,10 +2028,25 @@ export function createRunExecutor(deps: ExecutorDeps) {
name, name,
viaConnector, 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 = const requiresApprovalByDefault =
requiresUnattendedApproval || toolRequiresApproval(name, viaConnector); requiresUnattendedApproval ||
toolRequiresApproval(name, viaConnector) ||
gitShellNeedsApproval;
const requiresMandatoryApproval = const requiresMandatoryApproval =
requiresUnattendedApproval || toolRequiresExplicitApproval(name); requiresUnattendedApproval || toolRequiresExplicitApproval(name) || gitShellNeedsApproval;
const connectorKind = connectorKindFromToolName( const connectorKind = connectorKindFromToolName(
name, name,
connectedPlugins.map((plugin) => plugin.provider), connectedPlugins.map((plugin) => plugin.provider),
@ -2040,8 +2081,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
autoReviewEnabled: autoReviewPref, autoReviewEnabled: autoReviewPref,
checkerConfigured, 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"; let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow";
if (gitShellNeedsApproval && nextApprovedTool) {
gitPushApprovedRuns.add(runId);
}
const needsApprovalEarly = plan === "ask" || plan === "judge"; const needsApprovalEarly = plan === "ask" || plan === "judge";
// A resumed approval keeps its key even if "Always allow" changed the policy. // A resumed approval keeps its key even if "Always allow" changed the policy.
const usesApprovalKey = const usesApprovalKey =
@ -4264,6 +4311,51 @@ export function createRunExecutor(deps: ExecutorDeps) {
}, },
context, context,
); );
const runStallMs = runStallWithoutProgressMs();
let lastUserVisibleProgressAt = Date.now();
const touchUserVisibleProgress = () => {
lastUserVisibleProgressAt = Date.now();
};
const pauseForRunStall = async (): Promise<boolean> => {
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)) { for await (const event of withRuntimeCleanup(runtimeEvents, runAbortController)) {
if (approvalPausePending) return; if (approvalPausePending) return;
if (!leaseValid) return; if (!leaseValid) return;
@ -4272,8 +4364,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
lastLeaseCheckAt = now; lastLeaseCheckAt = now;
if (!(await verifyLeaseOwnership())) return; if (!(await verifyLeaseOwnership())) return;
} }
if (await pauseForRunStall()) return;
if (event.type === "text") { if (event.type === "text") {
touchUserVisibleProgress();
assembled += event.text; assembled += event.text;
currentTextSegment += event.text; currentTextSegment += event.text;
toolCallStreak = { key: undefined, count: 0 }; toolCallStreak = { key: undefined, count: 0 };
@ -4284,6 +4378,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
await flushProgress(); await flushProgress();
} }
} else if (event.type === "progress") { } else if (event.type === "progress") {
touchUserVisibleProgress();
toolCallStreak = { key: undefined, count: 0 }; toolCallStreak = { key: undefined, count: 0 };
// Flush batched text deltas first so an activity line cannot land // Flush batched text deltas first so an activity line cannot land
// ahead of text the model streamed before the tool call. // ahead of text the model streamed before the tool call.
@ -4403,13 +4498,34 @@ export function createRunExecutor(deps: ExecutorDeps) {
}); });
return; return;
} else if (event.type === "tool") { } else if (event.type === "tool") {
touchUserVisibleProgress();
// Preserve event ordering when the throttle still holds recent narration: the // Preserve event ordering when the throttle still holds recent narration: the
// client must see that text before the tool call it describes. // client must see that text before the tool call it describes.
await flushProgress(); await flushProgress();
// Promote streamed narration into a durable, replyable chat message before // Promote streamed narration into a durable, replyable chat message before
// tools continue, so long turns do not look stalled and stay replyable. // tools continue, so long turns do not look stalled and stay replyable.
if (event.name !== "message_user") { 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({ await deps.events.append({
spaceId: run.spaceId, spaceId: run.spaceId,

View File

@ -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");
});
});

View File

@ -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);
}

View File

@ -69,7 +69,7 @@ const toolCallBudgetsByRun = new Map<string, ToolCallBudget>();
/** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */ /** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */
export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number { export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim(); const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim();
if (!raw) return 0; if (!raw) return 48;
const parsed = Number(raw); const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return 0; if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.floor(parsed); return Math.floor(parsed);
@ -80,6 +80,10 @@ export function toolCallBudgetFor(runId: string): ToolCallBudget {
if (existing) { if (existing) {
existing.inFlight = 0; existing.inFlight = 0;
existing.limit = maxToolCallsPerTurn(); existing.limit = maxToolCallsPerTurn();
if (existing.exceeded) {
existing.exceeded = false;
existing.count = 0;
}
return existing; return existing;
} }
const budget: ToolCallBudget = { const budget: ToolCallBudget = {
@ -141,7 +145,16 @@ export function endToolCall(host: { toolCallBudget: ToolCallBudget; abortTurn():
} }
export function toolCallBudgetExceededMessage(limit: number): string { 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 --- // --- Model resolution ---

View File

@ -872,7 +872,8 @@ describe("Pi connector tool dispatch", () => {
expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." }); 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"; fakeAgentState.mode = "parent-limit";
const executeTool = vi.fn(async () => ({ ok: true })); const executeTool = vi.fn(async () => ({ ok: true }));
const runtime = new PiAgentRuntime(); const runtime = new PiAgentRuntime();
@ -909,6 +910,7 @@ describe("Pi connector tool dispatch", () => {
text: expect.stringContaining("tool calls in one turn"), 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 () => { 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.", text: "Stopped: more than 80 tool calls in one turn.",
}); });
expect(events).toContainEqual({ expect(events).toContainEqual({
type: "text", type: "ask",
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", 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: [
expect(events.at(-1)).toEqual({ { id: "continue-tools", label: "Continue with more tool calls" },
type: "done", { id: "stop-tools", label: "Stop for now" },
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" });
}); });
it("shares an optional tool-call fuse with subagents and still emits a final message", async () => { 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.", text: "Stopped: more than 80 tool calls in one turn.",
}); });
expect(events).toContainEqual({ expect(events).toContainEqual({
type: "text", type: "ask",
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", 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: [
expect(events).toContainEqual({ { id: "continue-tools", label: "Continue with more tool calls" },
type: "done", { id: "stop-tools", label: "Stop for now" },
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.", ],
}); });
expect(events).toContainEqual({ type: "done" });
const subagentEvents = events.filter( const subagentEvents = events.filter(
(event): event is { type: "subagent"; status: string; result?: string } => (event): event is { type: "subagent"; status: string; result?: string } =>
typeof event === "object" && typeof event === "object" &&
@ -1021,14 +1025,14 @@ describe("Pi connector tool dispatch", () => {
type: "subagent", type: "subagent",
status: "completed", status: "completed",
result: 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", () => { it("treats unset, empty, and non-positive MAX_TOOL_CALLS_PER_TURN as unlimited", () => {
expect(maxToolCallsPerTurn({})).toBe(0); expect(maxToolCallsPerTurn({})).toBe(48);
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(0); 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: "0" })).toBe(0);
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0); expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0);
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0); expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0);

View File

@ -376,15 +376,16 @@ export class PiAgentRuntime implements AgentRuntime {
throw new Error(sanitizeProviderError(model.provider, error)); throw new Error(sanitizeProviderError(model.provider, error));
} }
if (budgetExceeded) { if (budgetExceeded) {
host.pausePending = true;
const budgetMessage = toolCallBudgetExceededMessage(host.toolCallBudget.limit); const budgetMessage = toolCallBudgetExceededMessage(host.toolCallBudget.limit);
if (streamed.trim()) { queue.push({
const suffix = `\n\n${budgetMessage}`; type: "ask",
queue.push({ type: "text", text: suffix }); text: budgetMessage,
streamed += suffix; actions: [
} else { { id: "continue-tools", label: "Continue with more tool calls" },
queue.push({ type: "text", text: budgetMessage }); { id: "stop-tools", label: "Stop for now" },
streamed = budgetMessage; ],
} });
} else if (!host.pausePending && toolWorkPendingFinal) { } else if (!host.pausePending && toolWorkPendingFinal) {
// Discard cumulative pre-tool narration from the terminal payload and make the // Discard cumulative pre-tool narration from the terminal payload and make the
// missing final response visible to the user instead of silently completing. // missing final response visible to the user instead of silently completing.
@ -413,8 +414,7 @@ export class PiAgentRuntime implements AgentRuntime {
} finally { } finally {
queue.close(); queue.close();
if (trackedBudget) { if (trackedBudget) {
const keepForResume = const keepForResume = signal.aborted || Boolean(resumeHost?.pausePending);
(signal.aborted || Boolean(resumeHost?.pausePending)) && !trackedBudget.exceeded;
releaseToolCallBudget(request.runId, keepForResume); releaseToolCallBudget(request.runId, keepForResume);
} }
} }

View File

@ -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<string | undefined> {
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;
}
}

View File

@ -1163,6 +1163,7 @@ export const DeploymentSettingsSchema = z.object({
signupsEnabled: z.boolean(), signupsEnabled: z.boolean(),
signupAllowlist: z.array(z.string()), signupAllowlist: z.array(z.string()),
hasDeploymentModelCredential: z.boolean(), hasDeploymentModelCredential: z.boolean(),
hasOwnerGithubToken: z.boolean(),
defaultProvider: z.string().nullable(), defaultProvider: z.string().nullable(),
defaultModel: z.string().nullable(), defaultModel: z.string().nullable(),
computerHost: z.enum(["docker", "this-mac"]).nullable(), computerHost: z.enum(["docker", "this-mac"]).nullable(),

View File

@ -273,6 +273,8 @@ export const appContract = {
signupsEnabled: z.boolean().optional(), signupsEnabled: z.boolean().optional(),
signupAllowlist: z.array(z.string()).optional(), signupAllowlist: z.array(z.string()).optional(),
computerHost: z.enum(["docker", "this-mac"]).nullable().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), .output(DeploymentSettingsSchema),

View File

@ -8,6 +8,7 @@ import {
endsSentence, endsSentence,
humanizeToolName, humanizeToolName,
isRunTerminalEvent, isRunTerminalEvent,
progressMessageText,
projectMessages, projectMessages,
reduceLiveMessageBlocks, reduceLiveMessageBlocks,
runFailureError, 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", () => { describe("reduceLiveMessageBlocks", () => {
it("preserves structured live activity markers", () => { it("preserves structured live activity markers", () => {
expect( expect(

View File

@ -307,9 +307,15 @@ export function progressMessageText(
payload: Record<string, unknown> | undefined, payload: Record<string, unknown> | undefined,
previousText = "", previousText = "",
): string { ): string {
return typeof payload?.delta === "string" if (typeof payload?.delta === "string") return previousText + payload.delta;
? previousText + payload.delta const text = payload?.text;
: String(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( export function subagentBlockFromPayload(

View File

@ -41,7 +41,10 @@ describe("readGitCheckout", () => {
return { ok: true, output: "abc123def456\n" }; return { ok: true, output: "abc123def456\n" };
} }
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") { 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") { if (args.join(" ") === "remote get-url origin") {
return { ok: true, output: "https://github.com/example/demo.git\n" }; return { ok: true, output: "https://github.com/example/demo.git\n" };

View File

@ -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", "HEAD"], root),
runGit(["rev-parse", "--abbrev-ref", "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(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root),
runGit(["--no-optional-locks", ...gitStatusArgv()], root), runGit(["--no-optional-locks", ...gitStatusArgv()], root),
runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], 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 porcelain = parseGitStatusPorcelain(status.ok ? status.output : "");
const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : ""); const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : "");
const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])]; const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])];
@ -78,7 +84,7 @@ export async function readGitCheckout(
return { return {
present: true, present: true,
remoteUrl: remote.ok ? remote.output.trim() || null : null, 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, headCommit: head.ok ? head.output.trim() || null : null,
dirty: stateReadable ? dirtyPaths.length > 0 : true, dirty: stateReadable ? dirtyPaths.length > 0 : true,
dirtyPaths: stateReadable ? dirtyPaths : [], dirtyPaths: stateReadable ? dirtyPaths : [],

View File

@ -0,0 +1 @@
ALTER TABLE "deployment_settings" ADD COLUMN IF NOT EXISTS "ownerGithubTokenCipher" TEXT;

View File

@ -284,6 +284,7 @@ model DeploymentSettings {
defaultModelProvider String? defaultModelProvider String?
defaultModelId String? defaultModelId String?
deploymentModelCredentialCipher String? deploymentModelCredentialCipher String?
ownerGithubTokenCipher String?
computerHost String? computerHost String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -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. */ /** Name and role of a project's main bot, the one the user talks to about that project. */
export const PROJECT_MAIN_BOT_DEFAULTS = { export const PROJECT_MAIN_BOT_DEFAULTS = {
name: "Chief", name: "Lead",
title: "Project lead", title: "Project lead",
description: "Leads this project and hands its work to specialists.", description: "Leads this project and hands its work to specialists.",
} as const; } as const;

View File

@ -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/);
});
});

View File

@ -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.");
}
}

View File

@ -2,6 +2,7 @@ import { realpath, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { readGitCheckout } from "@rakazo/core/git-checkout"; import { readGitCheckout } from "@rakazo/core/git-checkout";
import type { PrismaClient } from "./client.js"; import type { PrismaClient } from "./client.js";
import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js";
export class InvalidProjectWorkspacePathError extends Error { export class InvalidProjectWorkspacePathError extends Error {
constructor(message: string) { constructor(message: string) {
@ -36,6 +37,7 @@ export async function resolveProjectWorkspaceDirectory(workspacePath: string): P
if (!info.isDirectory()) { if (!info.isDirectory()) {
throw new InvalidProjectWorkspacePathError("That path is not a folder."); throw new InvalidProjectWorkspacePathError("That path is not a folder.");
} }
assertProjectWorkspaceMountAllowed(resolved);
return resolved; return resolved;
} }

View File

@ -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);
});
});

View File

@ -376,7 +376,7 @@ if [[ "$*" == "dev --filter=!@rakazo/web" ]]; then exec sleep 30; fi
if [[ "$1" == --filter ]]; then if [[ "$1" == --filter ]]; then
# Exit only after the services were started, so both launches are on record. # Exit only after the services were started, so both launches are on record.
for _ in $(seq 50); do 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 sleep 0.1
done done
exit 4 exit 4