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.",
|
"This Mac mode is only available when SANDBOX_PROVIDER=docker on a personal local app.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let ownerGithubTokenCipher: string | null | undefined;
|
||||||
|
if (input.ownerGithubToken !== undefined) {
|
||||||
|
if (input.ownerGithubToken === null) {
|
||||||
|
ownerGithubTokenCipher = null;
|
||||||
|
} else {
|
||||||
|
const stored = await deps.secrets.put(input.ownerGithubToken, {
|
||||||
|
operationId: "deployment-github-token",
|
||||||
|
traceId: "deployment-github-token",
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
ownerGithubTokenCipher = stored.ciphertext;
|
||||||
|
}
|
||||||
|
}
|
||||||
await deps.prisma.deploymentSettings.upsert({
|
await deps.prisma.deploymentSettings.upsert({
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
create: {
|
create: {
|
||||||
@ -993,6 +1008,9 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
signupAllowlist: (input.signupAllowlist ?? []).join(","),
|
signupAllowlist: (input.signupAllowlist ?? []).join(","),
|
||||||
signupPolicyInitialized: true,
|
signupPolicyInitialized: true,
|
||||||
computerHost: input.computerHost ?? undefined,
|
computerHost: input.computerHost ?? undefined,
|
||||||
|
...(ownerGithubTokenCipher !== undefined
|
||||||
|
? { ownerGithubTokenCipher }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }),
|
...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }),
|
||||||
@ -1001,6 +1019,9 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
? {}
|
? {}
|
||||||
: { signupPolicyInitialized: true }),
|
: { signupPolicyInitialized: true }),
|
||||||
...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }),
|
...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }),
|
||||||
|
...(ownerGithubTokenCipher !== undefined
|
||||||
|
? { ownerGithubTokenCipher }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return deploymentDto(deps.prisma, deps.env.sandboxProvider);
|
return deploymentDto(deps.prisma, deps.env.sandboxProvider);
|
||||||
@ -5744,6 +5765,7 @@ async function deploymentDto(prisma: PrismaClient, sandboxProvider: string) {
|
|||||||
? settings.signupAllowlist.split(",").filter(Boolean)
|
? settings.signupAllowlist.split(",").filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher),
|
hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher),
|
||||||
|
hasOwnerGithubToken: Boolean(settings?.ownerGithubTokenCipher),
|
||||||
defaultProvider: settings?.defaultModelProvider ?? null,
|
defaultProvider: settings?.defaultModelProvider ?? null,
|
||||||
defaultModel: settings?.defaultModelId ?? null,
|
defaultModel: settings?.defaultModelId ?? null,
|
||||||
computerHost: computerHostFor(settings?.computerHost, sandboxProvider),
|
computerHost: computerHostFor(settings?.computerHost, sandboxProvider),
|
||||||
|
|||||||
@ -27,7 +27,7 @@ test("a new project opens straight into the conversation with its own main agent
|
|||||||
|
|
||||||
// The project is one conversation, led by a main agent of its own.
|
// The project is one conversation, led by a main agent of its own.
|
||||||
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
|
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
|
||||||
name: "Chief, main conversation",
|
name: "Lead, main conversation",
|
||||||
});
|
});
|
||||||
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
|
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||||
|
|||||||
@ -44,7 +44,7 @@ test("Projects + opens new project dialog instead of group form", async ({ page
|
|||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
projectSpace(projekte, "Customer project").getByRole("button", {
|
projectSpace(projekte, "Customer project").getByRole("button", {
|
||||||
name: "Chief, main conversation",
|
name: "Lead, main conversation",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
|
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
|
||||||
@ -169,7 +169,7 @@ test("a new space opens its main conversation and can be deleted from its menu",
|
|||||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(
|
await expect(
|
||||||
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
|
projectSpace(projekte, "Temporary").getByRole("button", { name: "Lead, main conversation" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
|
||||||
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
|
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
|
||||||
@ -243,7 +243,7 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
// The project's main conversation stays, so the project opens there.
|
// The project's main conversation stays, so the project opens there.
|
||||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
await expect(
|
await expect(
|
||||||
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
|
projectSpace(projekte, "Side").getByRole("button", { name: "Lead, main conversation" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
|
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { BotAvatar } from "@rakazo/ui-web";
|
import { BotAvatar } from "@rakazo/ui-web";
|
||||||
import { ArrowRightLeft, GitBranch, MessageCircle, Users } from "lucide-react";
|
import { ArrowRightLeft, GitBranch, MessageCircle, Users } from "lucide-react";
|
||||||
import type { VoiceAwarenessEvent } from "../../lib/bot-persona";
|
import type { VoiceAwarenessEvent } from "../../lib/bot-persona";
|
||||||
@ -13,6 +14,7 @@ export function VoiceAwarenessStrip({
|
|||||||
botColor: string;
|
botColor: string;
|
||||||
botId: string;
|
botId: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
const Icon =
|
const Icon =
|
||||||
event.kind === "subagent"
|
event.kind === "subagent"
|
||||||
? Users
|
? Users
|
||||||
@ -24,6 +26,19 @@ export function VoiceAwarenessStrip({
|
|||||||
? MessageCircle
|
? MessageCircle
|
||||||
: Users;
|
: Users;
|
||||||
|
|
||||||
|
const kindLabel =
|
||||||
|
event.kind === "subagent"
|
||||||
|
? t`Collaborating`
|
||||||
|
: event.kind === "handoff"
|
||||||
|
? t`Handoff`
|
||||||
|
: event.kind === "delegation"
|
||||||
|
? t`Delegated`
|
||||||
|
: event.kind === "peer-reply"
|
||||||
|
? t`Peer reply`
|
||||||
|
: event.kind === "working"
|
||||||
|
? t`In progress`
|
||||||
|
: t`Aware`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@ -34,7 +49,7 @@ export function VoiceAwarenessStrip({
|
|||||||
<div className="min-w-0 flex-1 text-start">
|
<div className="min-w-0 flex-1 text-start">
|
||||||
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
|
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
|
||||||
<span>{awarenessKindLabel(event.kind)}</span>
|
<span>{kindLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
|
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
|
||||||
{label}
|
{label}
|
||||||
@ -43,20 +58,3 @@ export function VoiceAwarenessStrip({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function awarenessKindLabel(kind: VoiceAwarenessEvent["kind"]): string {
|
|
||||||
switch (kind) {
|
|
||||||
case "subagent":
|
|
||||||
return "Collaborating";
|
|
||||||
case "handoff":
|
|
||||||
return "Handoff";
|
|
||||||
case "delegation":
|
|
||||||
return "Delegated";
|
|
||||||
case "peer-reply":
|
|
||||||
return "Peer reply";
|
|
||||||
case "working":
|
|
||||||
return "In progress";
|
|
||||||
default:
|
|
||||||
return "Aware";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import type { ThreadSnapshot } from "@rakazo/contracts";
|
import type { ThreadSnapshot } from "@rakazo/contracts";
|
||||||
|
import { i18n } from "./i18n";
|
||||||
import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web";
|
import { type EvooPose, mapPersonaArchetypeToIdlePose, mapVoicePhaseToPose } from "@rakazo/ui-web";
|
||||||
import type { StagePresencePhase } from "../components/stage/StageHero";
|
import type { StagePresencePhase } from "../components/stage/StageHero";
|
||||||
import type { VoicePhase } from "../components/voice/VoiceStage";
|
import type { VoicePhase } from "../components/voice/VoiceStage";
|
||||||
@ -462,7 +464,7 @@ function narrateWorking(archetype: PersonaArchetype, name: string): string {
|
|||||||
case "engineer":
|
case "engineer":
|
||||||
return `${name} is heads-down on this.`;
|
return `${name} is heads-down on this.`;
|
||||||
default:
|
default:
|
||||||
return `${name} is working on your request.`;
|
return i18n._(msg`${name} is working on your request.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -93,6 +93,14 @@ msgstr "{0} ist da"
|
|||||||
msgid "{0} is working…"
|
msgid "{0} is working…"
|
||||||
msgstr "{0} arbeitet…"
|
msgstr "{0} arbeitet…"
|
||||||
|
|
||||||
|
#: src/lib/bot-persona.ts
|
||||||
|
msgid "{name} is working on your request."
|
||||||
|
msgstr "{name} arbeitet an deiner Anfrage."
|
||||||
|
|
||||||
|
#: src/components/voice/VoiceAwarenessStrip.tsx
|
||||||
|
msgid "In progress"
|
||||||
|
msgstr "In Arbeit"
|
||||||
|
|
||||||
#. placeholder {0}: format(size / 1024)
|
#. placeholder {0}: format(size / 1024)
|
||||||
#: src/lib/format-bytes.ts
|
#: src/lib/format-bytes.ts
|
||||||
msgid "{0} KB"
|
msgid "{0} KB"
|
||||||
|
|||||||
@ -154,6 +154,26 @@ Severity: **P0** ship blocker · **P1** major UX/trust · **P2** polish · **P3*
|
|||||||
| R2-6 | P2 | Deep link / reload matrix | **`route-deep-link-matrix`** |
|
| R2-6 | P2 | Deep link / reload matrix | **`route-deep-link-matrix`** |
|
||||||
| R2-7 | P2 | Empty project / SessionUnavailable / teaching composer E2E | **`empty-project-state`**, **`session-unavailable`**, **`teaching-composer`** |
|
| R2-7 | P2 | Empty project / SessionUnavailable / teaching composer E2E | **`empty-project-state`**, **`session-unavailable`**, **`teaching-composer`** |
|
||||||
|
|
||||||
|
### Fixed in Phase 3 (real code work)
|
||||||
|
|
||||||
|
| ID | Sev | Issue | Fix |
|
||||||
|
|----|-----|-------|-----|
|
||||||
|
| R3-git | P0 | Computer image missing git/gh/ssh/curl/sudo | `infra/sandboxes/computer/Dockerfile` + `computer-image-tools.test.sh` |
|
||||||
|
| R3-mount | P0 | `Space.workspacePath` not mounted in Oo | Bind at `/home/rakazo/project` for deployment owner project spaces |
|
||||||
|
| R3-path | P0 | `/home/rakazo/...` file paths doubled | `stripVirtualWorkspaceRoot` + tests |
|
||||||
|
| R3-branch | P0 | Default branch from checkout not remote | `readGitCheckout` uses `origin/HEAD` |
|
||||||
|
| R3-push | P0 | Unrestricted git push / force-push | `git-shell-policy.ts` + approval for first push/`gh pr` |
|
||||||
|
| R3-token | P0 | No owner GitHub credential path | Encrypted `ownerGithubTokenCipher` + `deployment.update` (write-only) |
|
||||||
|
| R3-runaway | P1 | Runaway tool loops | Default 48 tool fuse → `need-user` ask; 15m stall ask |
|
||||||
|
| R3-ux | P1 | Narration flood / stream fragments | Activity line + streaming text merge in `progressMessageText` |
|
||||||
|
| R3-i18n | P2 | English run chrome in DE | Lingui for awareness strip + working narration |
|
||||||
|
| R3-name | P2 | Duplicate “Chief” in project sidebar | Project main Oo defaults to **Lead** |
|
||||||
|
| R3-flake | P1 | `evo-deploy.test.ts` grep flake | Fixed `--filter` pattern in stack fixture |
|
||||||
|
|
||||||
|
**Evo ops:** Rebuild `rakazo/computer:local` on Evo after merge (`pnpm sandbox:build` or compose `computer-image` target). Server deploy via `ops/evo-deploy/deploy.sh` updates app images; the computer image is separate—rebuild and restart sandboxes so new containers pick up git tools.
|
||||||
|
|
||||||
|
**Security (GitHub token):** Token is stored only as ciphertext in `deployment_settings.ownerGithubTokenCipher`, set by the deployment owner through `deployment.update`. It is injected as `GITHUB_TOKEN`/`GH_TOKEN` only for the owner’s runs in non-default (project) spaces—not baked into the image or repo. Push/PR policy is enforced in the shell tool layer regardless of token presence.
|
||||||
|
|
||||||
### Remaining — Phase 3+
|
### Remaining — Phase 3+
|
||||||
|
|
||||||
| ID | Sev | Issue | Notes |
|
| ID | Sev | Issue | Notes |
|
||||||
|
|||||||
@ -29,6 +29,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
procps \
|
procps \
|
||||||
bash \
|
bash \
|
||||||
|
git \
|
||||||
|
gh \
|
||||||
|
openssh-client \
|
||||||
|
curl \
|
||||||
|
sudo \
|
||||||
chromium \
|
chromium \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
fonts-dejavu-core \
|
fonts-dejavu-core \
|
||||||
@ -37,6 +42,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --gid 1000 rakazo \
|
&& groupadd --gid 1000 rakazo \
|
||||||
&& useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash rakazo \
|
&& useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash rakazo \
|
||||||
|
&& usermod -aG sudo rakazo \
|
||||||
|
&& printf '%s\n' 'rakazo ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/rakazo \
|
||||||
|
&& chmod 440 /etc/sudoers.d/rakazo \
|
||||||
&& mkdir -p /etc/rakazo/fluxbox \
|
&& mkdir -p /etc/rakazo/fluxbox \
|
||||||
&& printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \
|
&& printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \
|
||||||
&& chmod +x /usr/bin/fbsetbg \
|
&& chmod +x /usr/bin/fbsetbg \
|
||||||
|
|||||||
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/);
|
expect(() => resolveSpaceComputerLimit(value)).toThrow(/positive integer/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("binds a linked project folder at /home/rakazo/project", () => {
|
||||||
|
const options = containerCreateOptions({
|
||||||
|
name: "rakazo-bot-abc",
|
||||||
|
image: COMPUTER_IMAGE,
|
||||||
|
botId: "abc",
|
||||||
|
spaceId: "ws",
|
||||||
|
homePath: "/var/rakazo/homes/abc",
|
||||||
|
projectWorkspacePath: "/var/rakazo/projects/demo",
|
||||||
|
networkMode: "rakazo_default",
|
||||||
|
});
|
||||||
|
const mounts = options.HostConfig.Mounts ?? [];
|
||||||
|
expect(mounts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
Type: "bind",
|
||||||
|
Source: "/var/rakazo/projects/demo",
|
||||||
|
Target: "/home/rakazo/project",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a VNC desktop, not an alpine sleep fallback", () => {
|
it("creates a VNC desktop, not an alpine sleep fallback", () => {
|
||||||
const options = containerCreateOptions({
|
const options = containerCreateOptions({
|
||||||
name: "rakazo-bot-abc",
|
name: "rakazo-bot-abc",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
|
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
|
||||||
import type Docker from "dockerode";
|
import type Docker from "dockerode";
|
||||||
@ -214,6 +215,21 @@ export function homeVolumeMatches(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validates a host folder before bind-mounting it into a project computer. */
|
||||||
|
export function assertProjectWorkspaceBindPath(hostPath: string): string {
|
||||||
|
const resolved = path.resolve(hostPath);
|
||||||
|
let stat: ReturnType<typeof statSync>;
|
||||||
|
try {
|
||||||
|
stat = statSync(resolved);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Project workspace path does not exist on the host.");
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new Error("Project workspace path must be a directory on the host.");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComputerCreateInput {
|
export interface ComputerCreateInput {
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
@ -221,6 +237,8 @@ export interface ComputerCreateInput {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
homePath: string;
|
homePath: string;
|
||||||
homeVolume?: { name: string; subpath: string };
|
homeVolume?: { name: string; subpath: string };
|
||||||
|
/** Host path to bind read-write at /home/rakazo/project (deployment owner project spaces). */
|
||||||
|
projectWorkspacePath?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
controlToken?: string;
|
controlToken?: string;
|
||||||
networkMode?: string;
|
networkMode?: string;
|
||||||
@ -240,8 +258,33 @@ export type SandboxInput =
|
|||||||
| PointerInput
|
| PointerInput
|
||||||
| { kind: "clipboard"; text: string };
|
| { kind: "clipboard"; text: string };
|
||||||
|
|
||||||
|
function computerVolumeMounts(input: ComputerCreateInput): Docker.MountSettings[] {
|
||||||
|
const mounts: Docker.MountSettings[] = [];
|
||||||
|
if (input.homeVolume) {
|
||||||
|
mounts.push({
|
||||||
|
Type: "volume",
|
||||||
|
Source: input.homeVolume.name,
|
||||||
|
Target: "/home/rakazo",
|
||||||
|
VolumeOptions: {
|
||||||
|
NoCopy: true,
|
||||||
|
Subpath: input.homeVolume.subpath,
|
||||||
|
} as Docker.MountSettings["VolumeOptions"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.projectWorkspacePath) {
|
||||||
|
mounts.push({
|
||||||
|
Type: "bind",
|
||||||
|
Source: input.projectWorkspacePath,
|
||||||
|
Target: "/home/rakazo/project",
|
||||||
|
ReadOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mounts;
|
||||||
|
}
|
||||||
|
|
||||||
export function containerCreateOptions(input: ComputerCreateInput) {
|
export function containerCreateOptions(input: ComputerCreateInput) {
|
||||||
const ports = computerPortBindings(input.publishControlPort);
|
const ports = computerPortBindings(input.publishControlPort);
|
||||||
|
const volumeMounts = computerVolumeMounts(input);
|
||||||
return {
|
return {
|
||||||
Image: input.image,
|
Image: input.image,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@ -262,23 +305,20 @@ export function containerCreateOptions(input: ComputerCreateInput) {
|
|||||||
},
|
},
|
||||||
ExposedPorts: ports.ExposedPorts,
|
ExposedPorts: ports.ExposedPorts,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
...(input.homeVolume
|
...(input.homeVolume || input.projectWorkspacePath
|
||||||
? {
|
? {
|
||||||
Binds: undefined,
|
Binds: undefined,
|
||||||
Mounts: [
|
Mounts: volumeMounts,
|
||||||
{
|
|
||||||
Type: "volume" as const,
|
|
||||||
Source: input.homeVolume.name,
|
|
||||||
Target: "/home/rakazo",
|
|
||||||
// Docker makes Labels and DriverConfig optional; dockerode's types do not.
|
|
||||||
VolumeOptions: {
|
|
||||||
NoCopy: true,
|
|
||||||
Subpath: input.homeVolume.subpath,
|
|
||||||
} as Docker.MountSettings["VolumeOptions"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
: { Binds: [`${input.homePath}:/home/rakazo`], Mounts: undefined }),
|
: {
|
||||||
|
Binds: [
|
||||||
|
`${input.homePath}:/home/rakazo`,
|
||||||
|
...(input.projectWorkspacePath
|
||||||
|
? [`${input.projectWorkspacePath}:/home/rakazo/project`]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
Mounts: undefined,
|
||||||
|
}),
|
||||||
PortBindings: ports.PortBindings,
|
PortBindings: ports.PortBindings,
|
||||||
ShmSize: 256 * 1024 * 1024,
|
ShmSize: 256 * 1024 * 1024,
|
||||||
CapDrop: ["ALL"],
|
CapDrop: ["ALL"],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@ -19,6 +19,7 @@ import { Hono, type MiddlewareHandler } from "hono";
|
|||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
assertProjectWorkspaceBindPath,
|
||||||
assertVolumeSubpathSupport,
|
assertVolumeSubpathSupport,
|
||||||
COMPUTER_GID,
|
COMPUTER_GID,
|
||||||
COMPUTER_IMAGE,
|
COMPUTER_IMAGE,
|
||||||
@ -156,6 +157,7 @@ app.post("/computers", async (c) => {
|
|||||||
botId: z.string().min(1),
|
botId: z.string().min(1),
|
||||||
homePath: z.string().min(1),
|
homePath: z.string().min(1),
|
||||||
spaceId: z.string().min(1),
|
spaceId: z.string().min(1),
|
||||||
|
projectWorkspacePath: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.parse(await c.req.json());
|
.parse(await c.req.json());
|
||||||
try {
|
try {
|
||||||
@ -233,6 +235,9 @@ app.post("/computers", async (c) => {
|
|||||||
? COMPUTER_GID
|
? COMPUTER_GID
|
||||||
: hostGid;
|
: hostGid;
|
||||||
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
|
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
|
||||||
|
const projectWorkspacePath = body.projectWorkspacePath
|
||||||
|
? assertProjectWorkspaceBindPath(body.projectWorkspacePath)
|
||||||
|
: undefined;
|
||||||
const name = containerNameFor(body.botId);
|
const name = containerNameFor(body.botId);
|
||||||
const createdNetwork =
|
const createdNetwork =
|
||||||
screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId);
|
screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId);
|
||||||
@ -248,6 +253,7 @@ app.post("/computers", async (c) => {
|
|||||||
botId: body.botId,
|
botId: body.botId,
|
||||||
spaceId: body.spaceId,
|
spaceId: body.spaceId,
|
||||||
...storage,
|
...storage,
|
||||||
|
...(projectWorkspacePath ? { projectWorkspacePath } : {}),
|
||||||
user: computerUser,
|
user: computerUser,
|
||||||
networkMode,
|
networkMode,
|
||||||
controlToken: randomUUID(),
|
controlToken: randomUUID(),
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export interface SandboxProvider {
|
|||||||
request: {
|
request: {
|
||||||
botId: string;
|
botId: string;
|
||||||
homePath: string;
|
homePath: string;
|
||||||
|
projectWorkspacePath?: string;
|
||||||
providerRef?: string;
|
providerRef?: string;
|
||||||
providerKind?: ComputerRef["kind"];
|
providerKind?: ComputerRef["kind"];
|
||||||
},
|
},
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
hasActiveComputerControl,
|
hasActiveComputerControl,
|
||||||
} from "./computer-control.js";
|
} from "./computer-control.js";
|
||||||
import { toComputerRef } from "./computer-support.js";
|
import { toComputerRef } from "./computer-support.js";
|
||||||
|
import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js";
|
||||||
import {
|
import {
|
||||||
checkpointComputerWorkspace,
|
checkpointComputerWorkspace,
|
||||||
ensureComputerWorkspaceLayout,
|
ensureComputerWorkspaceLayout,
|
||||||
@ -280,10 +281,20 @@ export async function provisionComputer(
|
|||||||
let provisioned: ComputerRef | undefined;
|
let provisioned: ComputerRef | undefined;
|
||||||
try {
|
try {
|
||||||
await onProgress?.("recreating");
|
await onProgress?.("recreating");
|
||||||
|
let projectWorkspacePath: string | undefined;
|
||||||
|
try {
|
||||||
|
projectWorkspacePath = await resolveProjectWorkspaceBind(deps.prisma, {
|
||||||
|
spaceId: context.spaceId,
|
||||||
|
userId: context.userId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
projectWorkspacePath = undefined;
|
||||||
|
}
|
||||||
const ref = await deps.sandbox.provision(
|
const ref = await deps.sandbox.provision(
|
||||||
{
|
{
|
||||||
botId: existing.homeKey,
|
botId: existing.homeKey,
|
||||||
homePath,
|
homePath,
|
||||||
|
projectWorkspacePath,
|
||||||
providerRef: existing.providerRef ?? undefined,
|
providerRef: existing.providerRef ?? undefined,
|
||||||
providerKind: existing.kind as ComputerRef["kind"],
|
providerKind: existing.kind as ComputerRef["kind"],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,6 +31,12 @@ describe("Team Computer bot folders", () => {
|
|||||||
);
|
);
|
||||||
expect(resolveBotWorkspacePath("team", "bot-1", "/root-note.md")).toBe("root-note.md");
|
expect(resolveBotWorkspacePath("team", "bot-1", "/root-note.md")).toBe("root-note.md");
|
||||||
expect(resolveBotWorkspacePath("team", "bot-1", "/shared/brief.md")).toBe("shared/brief.md");
|
expect(resolveBotWorkspacePath("team", "bot-1", "/shared/brief.md")).toBe("shared/brief.md");
|
||||||
|
expect(resolveBotWorkspacePath("team", "bot-1", "/home/rakazo/notes/x.md")).toBe(
|
||||||
|
"notes/x.md",
|
||||||
|
);
|
||||||
|
expect(resolveBotWorkspacePath("team", "bot-1", "/home/rakazo/project/src/main.ts")).toBe(
|
||||||
|
"project/src/main.ts",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presents the current bot folder as its home without hiding Team paths", () => {
|
it("presents the current bot folder as its home without hiding Team paths", () => {
|
||||||
|
|||||||
@ -239,9 +239,15 @@ function isTeamRootPath(value: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripVirtualWorkspaceRoot(value: string): string | null {
|
const VIRTUAL_COMPUTER_HOME = "/home/rakazo";
|
||||||
|
|
||||||
|
export function stripVirtualWorkspaceRoot(value: string): string | null {
|
||||||
const portable = value.replace(/\\/g, "/");
|
const portable = value.replace(/\\/g, "/");
|
||||||
if (portable === "/") return "";
|
if (portable === "/") return "";
|
||||||
|
if (portable === VIRTUAL_COMPUTER_HOME || portable === `${VIRTUAL_COMPUTER_HOME}/`) return "";
|
||||||
|
if (portable.startsWith(`${VIRTUAL_COMPUTER_HOME}/`)) {
|
||||||
|
return portable.slice(`${VIRTUAL_COMPUTER_HOME}/`.length);
|
||||||
|
}
|
||||||
return portable.startsWith("/") ? portable.slice(1) : null;
|
return portable.startsWith("/") ? portable.slice(1) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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(
|
async provision(
|
||||||
request: { botId: string; homePath: string },
|
request: { botId: string; homePath: string; projectWorkspacePath?: string },
|
||||||
context: AdapterContext,
|
context: AdapterContext,
|
||||||
): Promise<ComputerRef> {
|
): Promise<ComputerRef> {
|
||||||
const res = await fetch(this.url("/computers"), {
|
const res = await fetch(this.url("/computers"), {
|
||||||
@ -144,6 +144,9 @@ export class DockerSandboxProvider implements SandboxProvider {
|
|||||||
botId: request.botId,
|
botId: request.botId,
|
||||||
homePath: request.homePath,
|
homePath: request.homePath,
|
||||||
spaceId: context.spaceId,
|
spaceId: context.spaceId,
|
||||||
|
...(request.projectWorkspacePath
|
||||||
|
? { projectWorkspacePath: request.projectWorkspacePath }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
signal: context.signal,
|
signal: context.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -125,6 +125,9 @@ import {
|
|||||||
formatAgentEnvironmentInstruction,
|
formatAgentEnvironmentInstruction,
|
||||||
redactAgentCommandResult,
|
redactAgentCommandResult,
|
||||||
} from "./agent-environment.js";
|
} from "./agent-environment.js";
|
||||||
|
import { ownerGithubEnvironmentForRun } from "./deployment-github-token.js";
|
||||||
|
import { evaluateGitShellPolicy } from "./git-shell-policy.js";
|
||||||
|
import { runStallWithoutProgressMs } from "./pi-model-config.js";
|
||||||
import { buildApprovalAskBlock } from "./approval-ask.js";
|
import { buildApprovalAskBlock } from "./approval-ask.js";
|
||||||
import {
|
import {
|
||||||
approvalPausedToolResult,
|
approvalPausedToolResult,
|
||||||
@ -452,6 +455,7 @@ const BUILTIN_AGENT_TOOL_NAMES = new Set(builtinAgentTools.map((tool) => tool.na
|
|||||||
/** Cap the roster so a large Space cannot flood the prompt. */
|
/** Cap the roster so a large Space cannot flood the prompt. */
|
||||||
const BOT_DIRECTORY_LIMIT = 40;
|
const BOT_DIRECTORY_LIMIT = 40;
|
||||||
const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots.";
|
const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots.";
|
||||||
|
const gitPushApprovedRuns = new Set<string>();
|
||||||
/** Runs an accepted suggestion's stored call; it never calls a model. */
|
/** Runs an accepted suggestion's stored call; it never calls a model. */
|
||||||
const SUGGESTION_RUNTIME = new ScriptedAgentRuntime();
|
const SUGGESTION_RUNTIME = new ScriptedAgentRuntime();
|
||||||
|
|
||||||
@ -1164,6 +1168,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
storedConnections,
|
storedConnections,
|
||||||
defaultCredential,
|
defaultCredential,
|
||||||
settings,
|
settings,
|
||||||
|
spaceRecord,
|
||||||
configuredMemory,
|
configuredMemory,
|
||||||
savedSkills,
|
savedSkills,
|
||||||
agentSkills,
|
agentSkills,
|
||||||
@ -1192,7 +1197,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
findDefaultModelCredential(deps.prisma, run),
|
findDefaultModelCredential(deps.prisma, run),
|
||||||
deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }),
|
deps.prisma.deploymentSettings.findUnique({
|
||||||
|
where: { id: "default" },
|
||||||
|
select: {
|
||||||
|
ownerUserId: true,
|
||||||
|
ownerGithubTokenCipher: true,
|
||||||
|
deploymentModelCredentialCipher: true,
|
||||||
|
defaultModelProvider: true,
|
||||||
|
defaultModelId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
deps.prisma.space.findUnique({
|
||||||
|
where: { id: run.spaceId },
|
||||||
|
select: { isDefault: true, gitDefaultBranch: true },
|
||||||
|
}),
|
||||||
deps.memoryProviders.resolve({ spaceId: run.spaceId, userId: run.userId }),
|
deps.memoryProviders.resolve({ spaceId: run.spaceId, userId: run.userId }),
|
||||||
deps.prisma.taughtSkill.findMany({
|
deps.prisma.taughtSkill.findMany({
|
||||||
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" },
|
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" },
|
||||||
@ -1210,7 +1228,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}),
|
}),
|
||||||
loadUserContext(deps.prisma, { userId: run.userId, spaceId: run.spaceId }),
|
loadUserContext(deps.prisma, { userId: run.userId, spaceId: run.spaceId }),
|
||||||
]);
|
]);
|
||||||
const agentEnvironment = decryptAgentEnvironment(agentSecretRows, deps.secretStore);
|
const agentEnvironment = {
|
||||||
|
...decryptAgentEnvironment(agentSecretRows, deps.secretStore),
|
||||||
|
...ownerGithubEnvironmentForRun({
|
||||||
|
settings,
|
||||||
|
userId: run.userId,
|
||||||
|
spaceIsDefault: spaceRecord?.isDefault ?? true,
|
||||||
|
secrets: deps.secretStore,
|
||||||
|
}),
|
||||||
|
};
|
||||||
runSecrets.push(...Object.values(agentEnvironment));
|
runSecrets.push(...Object.values(agentEnvironment));
|
||||||
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment);
|
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment);
|
||||||
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
|
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
|
||||||
@ -1641,8 +1667,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
});
|
});
|
||||||
const workspaceInstruction =
|
const workspaceInstruction =
|
||||||
computerMode === "team"
|
computerMode === "team"
|
||||||
? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/.`
|
? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/. Absolute paths under /home/rakazo/ map to the same locations as project/, shared/, and bots/. Before appending to a file, read it first and skip content that is already present; prefer the edit tool for surgical changes. Git push and opening pull requests require the user's approval once per run; never force-push or push to the default branch.`
|
||||||
: "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root.";
|
: "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root. Before appending to a file, read it first and skip content that is already present; prefer the edit tool for surgical changes.";
|
||||||
|
|
||||||
let assembled = "";
|
let assembled = "";
|
||||||
let currentTextSegment = "";
|
let currentTextSegment = "";
|
||||||
@ -2002,10 +2028,25 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
name,
|
name,
|
||||||
viaConnector,
|
viaConnector,
|
||||||
);
|
);
|
||||||
|
const shellCommand = name === "shell" ? String(args.command ?? args.cmd ?? "") : "";
|
||||||
|
const gitShellPolicy =
|
||||||
|
name === "shell"
|
||||||
|
? evaluateGitShellPolicy({
|
||||||
|
command: shellCommand,
|
||||||
|
defaultBranch: spaceRecord?.gitDefaultBranch,
|
||||||
|
gitPushApprovedThisRun: gitPushApprovedRuns.has(runId),
|
||||||
|
})
|
||||||
|
: { action: "allow" as const };
|
||||||
|
if (gitShellPolicy.action === "block") {
|
||||||
|
return { error: gitShellPolicy.reason };
|
||||||
|
}
|
||||||
|
const gitShellNeedsApproval = gitShellPolicy.action === "require_approval";
|
||||||
const requiresApprovalByDefault =
|
const requiresApprovalByDefault =
|
||||||
requiresUnattendedApproval || toolRequiresApproval(name, viaConnector);
|
requiresUnattendedApproval ||
|
||||||
|
toolRequiresApproval(name, viaConnector) ||
|
||||||
|
gitShellNeedsApproval;
|
||||||
const requiresMandatoryApproval =
|
const requiresMandatoryApproval =
|
||||||
requiresUnattendedApproval || toolRequiresExplicitApproval(name);
|
requiresUnattendedApproval || toolRequiresExplicitApproval(name) || gitShellNeedsApproval;
|
||||||
const connectorKind = connectorKindFromToolName(
|
const connectorKind = connectorKindFromToolName(
|
||||||
name,
|
name,
|
||||||
connectedPlugins.map((plugin) => plugin.provider),
|
connectedPlugins.map((plugin) => plugin.provider),
|
||||||
@ -2040,8 +2081,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
autoReviewEnabled: autoReviewPref,
|
autoReviewEnabled: autoReviewPref,
|
||||||
checkerConfigured,
|
checkerConfigured,
|
||||||
});
|
});
|
||||||
let reviewReason: string | undefined;
|
let reviewReason: string | undefined =
|
||||||
|
gitShellNeedsApproval && typeof gitShellPolicy === "object" && "reason" in gitShellPolicy
|
||||||
|
? gitShellPolicy.reason
|
||||||
|
: undefined;
|
||||||
let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow";
|
let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow";
|
||||||
|
if (gitShellNeedsApproval && nextApprovedTool) {
|
||||||
|
gitPushApprovedRuns.add(runId);
|
||||||
|
}
|
||||||
const needsApprovalEarly = plan === "ask" || plan === "judge";
|
const needsApprovalEarly = plan === "ask" || plan === "judge";
|
||||||
// A resumed approval keeps its key even if "Always allow" changed the policy.
|
// A resumed approval keeps its key even if "Always allow" changed the policy.
|
||||||
const usesApprovalKey =
|
const usesApprovalKey =
|
||||||
@ -4264,6 +4311,51 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
const runStallMs = runStallWithoutProgressMs();
|
||||||
|
let lastUserVisibleProgressAt = Date.now();
|
||||||
|
const touchUserVisibleProgress = () => {
|
||||||
|
lastUserVisibleProgressAt = Date.now();
|
||||||
|
};
|
||||||
|
const pauseForRunStall = async (): Promise<boolean> => {
|
||||||
|
if (runStallMs <= 0 || scripted || approvalPausePending) return false;
|
||||||
|
if (Date.now() - lastUserVisibleProgressAt < runStallMs) return false;
|
||||||
|
if (!(await renewRunLease(deps, runId, workerId, fence))) return true;
|
||||||
|
await workspaceCheckpoint.flush();
|
||||||
|
const stalledText = `This run has been working for ${Math.round(runStallMs / 60_000)} minutes without a visible update. Choose whether to keep going or stop for now.`;
|
||||||
|
const paused = await deps.events.pauseRunForInput({
|
||||||
|
spaceId: run.spaceId,
|
||||||
|
threadId: run.threadId,
|
||||||
|
botId: run.botId,
|
||||||
|
runId,
|
||||||
|
attemptId: attempt.id,
|
||||||
|
leaseOwner: workerId,
|
||||||
|
leaseFence: fence,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: "ask",
|
||||||
|
text: stalledText,
|
||||||
|
status: "pending",
|
||||||
|
actions: [
|
||||||
|
{ id: "continue-run", label: "Keep going" },
|
||||||
|
{ id: "stop-run", label: "Stop for now" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
offeredActions: [
|
||||||
|
{ id: "continue-run", label: "Keep going" },
|
||||||
|
{ id: "stop-run", label: "Stop for now" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!paused) return true;
|
||||||
|
await notifyRun(deps, run, {
|
||||||
|
kind: "help",
|
||||||
|
title: `${bot.name} needs your call`,
|
||||||
|
body: stalledText,
|
||||||
|
botId: bot.id,
|
||||||
|
threadId: thread.id,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
for await (const event of withRuntimeCleanup(runtimeEvents, runAbortController)) {
|
for await (const event of withRuntimeCleanup(runtimeEvents, runAbortController)) {
|
||||||
if (approvalPausePending) return;
|
if (approvalPausePending) return;
|
||||||
if (!leaseValid) return;
|
if (!leaseValid) return;
|
||||||
@ -4272,8 +4364,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
lastLeaseCheckAt = now;
|
lastLeaseCheckAt = now;
|
||||||
if (!(await verifyLeaseOwnership())) return;
|
if (!(await verifyLeaseOwnership())) return;
|
||||||
}
|
}
|
||||||
|
if (await pauseForRunStall()) return;
|
||||||
|
|
||||||
if (event.type === "text") {
|
if (event.type === "text") {
|
||||||
|
touchUserVisibleProgress();
|
||||||
assembled += event.text;
|
assembled += event.text;
|
||||||
currentTextSegment += event.text;
|
currentTextSegment += event.text;
|
||||||
toolCallStreak = { key: undefined, count: 0 };
|
toolCallStreak = { key: undefined, count: 0 };
|
||||||
@ -4284,6 +4378,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
await flushProgress();
|
await flushProgress();
|
||||||
}
|
}
|
||||||
} else if (event.type === "progress") {
|
} else if (event.type === "progress") {
|
||||||
|
touchUserVisibleProgress();
|
||||||
toolCallStreak = { key: undefined, count: 0 };
|
toolCallStreak = { key: undefined, count: 0 };
|
||||||
// Flush batched text deltas first so an activity line cannot land
|
// Flush batched text deltas first so an activity line cannot land
|
||||||
// ahead of text the model streamed before the tool call.
|
// ahead of text the model streamed before the tool call.
|
||||||
@ -4403,13 +4498,34 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (event.type === "tool") {
|
} else if (event.type === "tool") {
|
||||||
|
touchUserVisibleProgress();
|
||||||
// Preserve event ordering when the throttle still holds recent narration: the
|
// Preserve event ordering when the throttle still holds recent narration: the
|
||||||
// client must see that text before the tool call it describes.
|
// client must see that text before the tool call it describes.
|
||||||
await flushProgress();
|
await flushProgress();
|
||||||
// Promote streamed narration into a durable, replyable chat message before
|
// Promote streamed narration into a durable, replyable chat message before
|
||||||
// tools continue, so long turns do not look stalled and stay replyable.
|
// tools continue, so long turns do not look stalled and stay replyable.
|
||||||
if (event.name !== "message_user") {
|
if (event.name !== "message_user") {
|
||||||
await publishMidTurnNarration();
|
const pendingNarration = extractNarrationText(messageSegments, currentTextSegment);
|
||||||
|
if (
|
||||||
|
pendingNarration.text.trim().length >= 120 &&
|
||||||
|
endsSentence(pendingNarration.text)
|
||||||
|
) {
|
||||||
|
await publishMidTurnNarration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.name === "shell") {
|
||||||
|
const command = String(event.args?.command ?? event.args?.cmd ?? "").trim();
|
||||||
|
const label = command
|
||||||
|
? `Running: ${command.length > 120 ? `${command.slice(0, 119)}…` : command}`
|
||||||
|
: `Using ${humanizeToolName(event.name)}…`;
|
||||||
|
await deps.events.append({
|
||||||
|
spaceId: run.spaceId,
|
||||||
|
threadId: thread.id,
|
||||||
|
botId: bot.id,
|
||||||
|
type: "thread.progress",
|
||||||
|
runId,
|
||||||
|
payload: { text: redactSecrets(label, runSecrets), activity: true },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await deps.events.append({
|
await deps.events.append({
|
||||||
spaceId: run.spaceId,
|
spaceId: run.spaceId,
|
||||||
|
|||||||
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). */
|
/** Optional self-host fuse. Unset, empty, or 0 means unlimited (default). */
|
||||||
export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number {
|
export function maxToolCallsPerTurn(env: NodeJS.ProcessEnv = process.env): number {
|
||||||
const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim();
|
const raw = env.MAX_TOOL_CALLS_PER_TURN?.trim();
|
||||||
if (!raw) return 0;
|
if (!raw) return 48;
|
||||||
const parsed = Number(raw);
|
const parsed = Number(raw);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
||||||
return Math.floor(parsed);
|
return Math.floor(parsed);
|
||||||
@ -80,6 +80,10 @@ export function toolCallBudgetFor(runId: string): ToolCallBudget {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
existing.inFlight = 0;
|
existing.inFlight = 0;
|
||||||
existing.limit = maxToolCallsPerTurn();
|
existing.limit = maxToolCallsPerTurn();
|
||||||
|
if (existing.exceeded) {
|
||||||
|
existing.exceeded = false;
|
||||||
|
existing.count = 0;
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const budget: ToolCallBudget = {
|
const budget: ToolCallBudget = {
|
||||||
@ -141,7 +145,16 @@ export function endToolCall(host: { toolCallBudget: ToolCallBudget; abortTurn():
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toolCallBudgetExceededMessage(limit: number): string {
|
export function toolCallBudgetExceededMessage(limit: number): string {
|
||||||
return `I stopped after reaching the limit of ${limit} tool calls in this turn. Send another message to continue.`;
|
return `I stopped after reaching the limit of ${limit} tool calls in this turn. Choose whether to continue with more tool calls or stop for now.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause a long run when no user-visible progress landed for this many milliseconds. */
|
||||||
|
export function runStallWithoutProgressMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||||
|
const raw = env.RUN_STALL_WITHOUT_PROGRESS_MS?.trim();
|
||||||
|
if (!raw) return 15 * 60_000;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
||||||
|
return Math.floor(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Model resolution ---
|
// --- Model resolution ---
|
||||||
|
|||||||
@ -872,7 +872,8 @@ describe("Pi connector tool dispatch", () => {
|
|||||||
expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." });
|
expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows more than 80 tool calls by default when no fuse is configured", async () => {
|
it("allows more than 80 tool calls when MAX_TOOL_CALLS_PER_TURN is unlimited", async () => {
|
||||||
|
process.env.MAX_TOOL_CALLS_PER_TURN = "0";
|
||||||
fakeAgentState.mode = "parent-limit";
|
fakeAgentState.mode = "parent-limit";
|
||||||
const executeTool = vi.fn(async () => ({ ok: true }));
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
||||||
const runtime = new PiAgentRuntime();
|
const runtime = new PiAgentRuntime();
|
||||||
@ -909,6 +910,7 @@ describe("Pi connector tool dispatch", () => {
|
|||||||
text: expect.stringContaining("tool calls in one turn"),
|
text: expect.stringContaining("tool calls in one turn"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
delete process.env.MAX_TOOL_CALLS_PER_TURN;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("soft-stops with a final message when MAX_TOOL_CALLS_PER_TURN is set", async () => {
|
it("soft-stops with a final message when MAX_TOOL_CALLS_PER_TURN is set", async () => {
|
||||||
@ -948,13 +950,14 @@ describe("Pi connector tool dispatch", () => {
|
|||||||
text: "Stopped: more than 80 tool calls in one turn.",
|
text: "Stopped: more than 80 tool calls in one turn.",
|
||||||
});
|
});
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: "text",
|
type: "ask",
|
||||||
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.",
|
||||||
});
|
actions: [
|
||||||
expect(events.at(-1)).toEqual({
|
{ id: "continue-tools", label: "Continue with more tool calls" },
|
||||||
type: "done",
|
{ id: "stop-tools", label: "Stop for now" },
|
||||||
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
],
|
||||||
});
|
});
|
||||||
|
expect(events.at(-1)).toEqual({ type: "done" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares an optional tool-call fuse with subagents and still emits a final message", async () => {
|
it("shares an optional tool-call fuse with subagents and still emits a final message", async () => {
|
||||||
@ -1001,13 +1004,14 @@ describe("Pi connector tool dispatch", () => {
|
|||||||
text: "Stopped: more than 80 tool calls in one turn.",
|
text: "Stopped: more than 80 tool calls in one turn.",
|
||||||
});
|
});
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: "text",
|
type: "ask",
|
||||||
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.",
|
||||||
});
|
actions: [
|
||||||
expect(events).toContainEqual({
|
{ id: "continue-tools", label: "Continue with more tool calls" },
|
||||||
type: "done",
|
{ id: "stop-tools", label: "Stop for now" },
|
||||||
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
],
|
||||||
});
|
});
|
||||||
|
expect(events).toContainEqual({ type: "done" });
|
||||||
const subagentEvents = events.filter(
|
const subagentEvents = events.filter(
|
||||||
(event): event is { type: "subagent"; status: string; result?: string } =>
|
(event): event is { type: "subagent"; status: string; result?: string } =>
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
@ -1021,14 +1025,14 @@ describe("Pi connector tool dispatch", () => {
|
|||||||
type: "subagent",
|
type: "subagent",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
result:
|
result:
|
||||||
"I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
"I stopped after reaching the limit of 80 tool calls in this turn. Choose whether to continue with more tool calls or stop for now.",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats unset, empty, and non-positive MAX_TOOL_CALLS_PER_TURN as unlimited", () => {
|
it("treats unset, empty, and non-positive MAX_TOOL_CALLS_PER_TURN as unlimited", () => {
|
||||||
expect(maxToolCallsPerTurn({})).toBe(0);
|
expect(maxToolCallsPerTurn({})).toBe(48);
|
||||||
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(0);
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(48);
|
||||||
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "0" })).toBe(0);
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "0" })).toBe(0);
|
||||||
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0);
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0);
|
||||||
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0);
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0);
|
||||||
|
|||||||
@ -376,15 +376,16 @@ export class PiAgentRuntime implements AgentRuntime {
|
|||||||
throw new Error(sanitizeProviderError(model.provider, error));
|
throw new Error(sanitizeProviderError(model.provider, error));
|
||||||
}
|
}
|
||||||
if (budgetExceeded) {
|
if (budgetExceeded) {
|
||||||
|
host.pausePending = true;
|
||||||
const budgetMessage = toolCallBudgetExceededMessage(host.toolCallBudget.limit);
|
const budgetMessage = toolCallBudgetExceededMessage(host.toolCallBudget.limit);
|
||||||
if (streamed.trim()) {
|
queue.push({
|
||||||
const suffix = `\n\n${budgetMessage}`;
|
type: "ask",
|
||||||
queue.push({ type: "text", text: suffix });
|
text: budgetMessage,
|
||||||
streamed += suffix;
|
actions: [
|
||||||
} else {
|
{ id: "continue-tools", label: "Continue with more tool calls" },
|
||||||
queue.push({ type: "text", text: budgetMessage });
|
{ id: "stop-tools", label: "Stop for now" },
|
||||||
streamed = budgetMessage;
|
],
|
||||||
}
|
});
|
||||||
} else if (!host.pausePending && toolWorkPendingFinal) {
|
} else if (!host.pausePending && toolWorkPendingFinal) {
|
||||||
// Discard cumulative pre-tool narration from the terminal payload and make the
|
// Discard cumulative pre-tool narration from the terminal payload and make the
|
||||||
// missing final response visible to the user instead of silently completing.
|
// missing final response visible to the user instead of silently completing.
|
||||||
@ -413,8 +414,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|||||||
} finally {
|
} finally {
|
||||||
queue.close();
|
queue.close();
|
||||||
if (trackedBudget) {
|
if (trackedBudget) {
|
||||||
const keepForResume =
|
const keepForResume = signal.aborted || Boolean(resumeHost?.pausePending);
|
||||||
(signal.aborted || Boolean(resumeHost?.pausePending)) && !trackedBudget.exceeded;
|
|
||||||
releaseToolCallBudget(request.runId, keepForResume);
|
releaseToolCallBudget(request.runId, keepForResume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
signupsEnabled: z.boolean(),
|
||||||
signupAllowlist: z.array(z.string()),
|
signupAllowlist: z.array(z.string()),
|
||||||
hasDeploymentModelCredential: z.boolean(),
|
hasDeploymentModelCredential: z.boolean(),
|
||||||
|
hasOwnerGithubToken: z.boolean(),
|
||||||
defaultProvider: z.string().nullable(),
|
defaultProvider: z.string().nullable(),
|
||||||
defaultModel: z.string().nullable(),
|
defaultModel: z.string().nullable(),
|
||||||
computerHost: z.enum(["docker", "this-mac"]).nullable(),
|
computerHost: z.enum(["docker", "this-mac"]).nullable(),
|
||||||
|
|||||||
@ -273,6 +273,8 @@ export const appContract = {
|
|||||||
signupsEnabled: z.boolean().optional(),
|
signupsEnabled: z.boolean().optional(),
|
||||||
signupAllowlist: z.array(z.string()).optional(),
|
signupAllowlist: z.array(z.string()).optional(),
|
||||||
computerHost: z.enum(["docker", "this-mac"]).nullable().optional(),
|
computerHost: z.enum(["docker", "this-mac"]).nullable().optional(),
|
||||||
|
/** Plaintext GitHub token for the deployment owner; null clears. Never returned on read. */
|
||||||
|
ownerGithubToken: z.string().min(1).max(400).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(DeploymentSettingsSchema),
|
.output(DeploymentSettingsSchema),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
endsSentence,
|
endsSentence,
|
||||||
humanizeToolName,
|
humanizeToolName,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
|
progressMessageText,
|
||||||
projectMessages,
|
projectMessages,
|
||||||
reduceLiveMessageBlocks,
|
reduceLiveMessageBlocks,
|
||||||
runFailureError,
|
runFailureError,
|
||||||
@ -37,6 +38,22 @@ describe("isRunTerminalEvent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("progressMessageText", () => {
|
||||||
|
it("appends streaming text chunks instead of replacing prior progress", () => {
|
||||||
|
const first = progressMessageText({ text: "se korrekt ", streaming: true });
|
||||||
|
expect(first).toBe("se korrekt ");
|
||||||
|
expect(progressMessageText({ text: "dekodieren:", streaming: true }, first)).toBe(
|
||||||
|
"se korrekt dekodieren:",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps activity lines as replacements", () => {
|
||||||
|
expect(
|
||||||
|
progressMessageText({ text: "Running: git status", activity: true }, "old narration"),
|
||||||
|
).toBe("Running: git status");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("reduceLiveMessageBlocks", () => {
|
describe("reduceLiveMessageBlocks", () => {
|
||||||
it("preserves structured live activity markers", () => {
|
it("preserves structured live activity markers", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -307,9 +307,15 @@ export function progressMessageText(
|
|||||||
payload: Record<string, unknown> | undefined,
|
payload: Record<string, unknown> | undefined,
|
||||||
previousText = "",
|
previousText = "",
|
||||||
): string {
|
): string {
|
||||||
return typeof payload?.delta === "string"
|
if (typeof payload?.delta === "string") return previousText + payload.delta;
|
||||||
? previousText + payload.delta
|
const text = payload?.text;
|
||||||
: String(payload?.text ?? "");
|
if (typeof text !== "string") return previousText;
|
||||||
|
if (payload?.activity === true) return text;
|
||||||
|
if (payload?.streaming === true && previousText) {
|
||||||
|
if (text.startsWith(previousText)) return text;
|
||||||
|
return previousText + text;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subagentBlockFromPayload(
|
export function subagentBlockFromPayload(
|
||||||
|
|||||||
@ -41,7 +41,10 @@ describe("readGitCheckout", () => {
|
|||||||
return { ok: true, output: "abc123def456\n" };
|
return { ok: true, output: "abc123def456\n" };
|
||||||
}
|
}
|
||||||
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") {
|
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") {
|
||||||
return { ok: true, output: "main\n" };
|
return { ok: true, output: "feature/login\n" };
|
||||||
|
}
|
||||||
|
if (args.join(" ") === "symbolic-ref --short refs/remotes/origin/HEAD") {
|
||||||
|
return { ok: true, output: "origin/main\n" };
|
||||||
}
|
}
|
||||||
if (args.join(" ") === "remote get-url origin") {
|
if (args.join(" ") === "remote get-url origin") {
|
||||||
return { ok: true, output: "https://github.com/example/demo.git\n" };
|
return { ok: true, output: "https://github.com/example/demo.git\n" };
|
||||||
|
|||||||
@ -62,14 +62,20 @@ export async function readGitCheckout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [head, branch, remote, status, untracked] = await Promise.all([
|
const [head, branch, remoteDefault, remote, status, untracked] = await Promise.all([
|
||||||
runGit(["rev-parse", "HEAD"], root),
|
runGit(["rev-parse", "HEAD"], root),
|
||||||
runGit(["rev-parse", "--abbrev-ref", "HEAD"], root),
|
runGit(["rev-parse", "--abbrev-ref", "HEAD"], root),
|
||||||
|
runGit(["symbolic-ref", "--short", `refs/remotes/${DEFAULT_UPDATE_REMOTE}/HEAD`], root),
|
||||||
runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root),
|
runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root),
|
||||||
runGit(["--no-optional-locks", ...gitStatusArgv()], root),
|
runGit(["--no-optional-locks", ...gitStatusArgv()], root),
|
||||||
runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root),
|
runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const remoteDefaultBranch = remoteDefault.ok
|
||||||
|
? remoteDefault.output.trim().replace(/^origin\//, "") || null
|
||||||
|
: null;
|
||||||
|
const checkedOutBranch = branch.ok ? branch.output.trim() || null : null;
|
||||||
|
|
||||||
const porcelain = parseGitStatusPorcelain(status.ok ? status.output : "");
|
const porcelain = parseGitStatusPorcelain(status.ok ? status.output : "");
|
||||||
const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : "");
|
const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : "");
|
||||||
const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])];
|
const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])];
|
||||||
@ -78,7 +84,7 @@ export async function readGitCheckout(
|
|||||||
return {
|
return {
|
||||||
present: true,
|
present: true,
|
||||||
remoteUrl: remote.ok ? remote.output.trim() || null : null,
|
remoteUrl: remote.ok ? remote.output.trim() || null : null,
|
||||||
defaultBranch: branch.ok ? branch.output.trim() || null : null,
|
defaultBranch: remoteDefaultBranch ?? checkedOutBranch,
|
||||||
headCommit: head.ok ? head.output.trim() || null : null,
|
headCommit: head.ok ? head.output.trim() || null : null,
|
||||||
dirty: stateReadable ? dirtyPaths.length > 0 : true,
|
dirty: stateReadable ? dirtyPaths.length > 0 : true,
|
||||||
dirtyPaths: stateReadable ? dirtyPaths : [],
|
dirtyPaths: stateReadable ? dirtyPaths : [],
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "deployment_settings" ADD COLUMN IF NOT EXISTS "ownerGithubTokenCipher" TEXT;
|
||||||
@ -284,6 +284,7 @@ model DeploymentSettings {
|
|||||||
defaultModelProvider String?
|
defaultModelProvider String?
|
||||||
defaultModelId String?
|
defaultModelId String?
|
||||||
deploymentModelCredentialCipher String?
|
deploymentModelCredentialCipher String?
|
||||||
|
ownerGithubTokenCipher String?
|
||||||
computerHost String?
|
computerHost String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const MAIN_BOT_DEFAULTS = {
|
|||||||
|
|
||||||
/** Name and role of a project's main bot, the one the user talks to about that project. */
|
/** Name and role of a project's main bot, the one the user talks to about that project. */
|
||||||
export const PROJECT_MAIN_BOT_DEFAULTS = {
|
export const PROJECT_MAIN_BOT_DEFAULTS = {
|
||||||
name: "Chief",
|
name: "Lead",
|
||||||
title: "Project lead",
|
title: "Project lead",
|
||||||
description: "Leads this project and hands its work to specialists.",
|
description: "Leads this project and hands its work to specialists.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
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 path from "node:path";
|
||||||
import { readGitCheckout } from "@rakazo/core/git-checkout";
|
import { readGitCheckout } from "@rakazo/core/git-checkout";
|
||||||
import type { PrismaClient } from "./client.js";
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js";
|
||||||
|
|
||||||
export class InvalidProjectWorkspacePathError extends Error {
|
export class InvalidProjectWorkspacePathError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@ -36,6 +37,7 @@ export async function resolveProjectWorkspaceDirectory(workspacePath: string): P
|
|||||||
if (!info.isDirectory()) {
|
if (!info.isDirectory()) {
|
||||||
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
|
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
|
||||||
}
|
}
|
||||||
|
assertProjectWorkspaceMountAllowed(resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
if [[ "$1" == --filter ]]; then
|
||||||
# Exit only after the services were started, so both launches are on record.
|
# Exit only after the services were started, so both launches are on record.
|
||||||
for _ in $(seq 50); do
|
for _ in $(seq 50); do
|
||||||
grep -q -e "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3
|
grep -qF -- "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
exit 4
|
exit 4
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user