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:
parent
a2c7182ed6
commit
c858cb816c
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 \
|
||||
|
||||
11
infra/sandboxes/computer/computer-image-tools.test.sh
Executable file
11
infra/sandboxes/computer/computer-image-tools.test.sh
Executable 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 "'
|
||||
@ -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",
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -94,6 +94,7 @@ export interface SandboxProvider {
|
||||
request: {
|
||||
botId: string;
|
||||
homePath: string;
|
||||
projectWorkspacePath?: string;
|
||||
providerRef?: string;
|
||||
providerKind?: ComputerRef["kind"];
|
||||
},
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
24
packages/adapters/src/deployment-github-token.test.ts
Normal file
24
packages/adapters/src/deployment-github-token.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
23
packages/adapters/src/deployment-github-token.ts
Normal file
23
packages/adapters/src/deployment-github-token.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
48
packages/adapters/src/git-shell-policy.test.ts
Normal file
48
packages/adapters/src/git-shell-policy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
81
packages/adapters/src/git-shell-policy.ts
Normal file
81
packages/adapters/src/git-shell-policy.ts
Normal 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);
|
||||
}
|
||||
@ -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 ---
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
26
packages/adapters/src/project-workspace-bind.ts
Normal file
26
packages/adapters/src/project-workspace-bind.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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" };
|
||||
|
||||
@ -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 : [],
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE "deployment_settings" ADD COLUMN IF NOT EXISTS "ownerGithubTokenCipher" TEXT;
|
||||
@ -284,6 +284,7 @@ model DeploymentSettings {
|
||||
defaultModelProvider String?
|
||||
defaultModelId String?
|
||||
deploymentModelCredentialCipher String?
|
||||
ownerGithubTokenCipher String?
|
||||
computerHost String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -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;
|
||||
|
||||
11
packages/db/src/project-workspace-mount.test.ts
Normal file
11
packages/db/src/project-workspace-mount.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
15
packages/db/src/project-workspace-mount.ts
Normal file
15
packages/db/src/project-workspace-mount.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
32
packages/testkit/src/computer-image-tools.test.ts
Normal file
32
packages/testkit/src/computer-image-tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user