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

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.
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);

View File

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

View File

@ -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 (
<div
role="status"
@ -34,7 +49,7 @@ export function VoiceAwarenessStrip({
<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">
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
<span>{awarenessKindLabel(event.kind)}</span>
<span>{kindLabel}</span>
</div>
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
{label}
@ -43,20 +58,3 @@ export function VoiceAwarenessStrip({
</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 { 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.`);
}
}

View File

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

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-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 |

View File

@ -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 \

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

View File

@ -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<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 {
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"],

View File

@ -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(),

View File

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

View File

@ -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"],
},

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", "/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", () => {

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

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(
request: { botId: string; homePath: string },
request: { botId: string; homePath: string; projectWorkspacePath?: string },
context: AdapterContext,
): Promise<ComputerRef> {
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,
});

View File

@ -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<string>();
/** 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<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)) {
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,

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). */
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 ---

View File

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

View File

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

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(),
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(),

View File

@ -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),

View File

@ -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(

View File

@ -307,9 +307,15 @@ export function progressMessageText(
payload: Record<string, unknown> | 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(

View File

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

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", "--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 : [],

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?
defaultModelId String?
deploymentModelCredentialCipher String?
ownerGithubTokenCipher String?
computerHost String?
createdAt DateTime @default(now())
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. */
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;

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 { 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;
}

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
# 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