diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46952c2..4b689f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,9 @@ jobs: changes: name: Detect product changes runs-on: [self-hosted, evoo-ci] + timeout-minutes: 10 outputs: - product: ${{ steps.filter.outputs.product }} + product: ${{ steps.product.outputs.product }} steps: - uses: actions/checkout@v5 with: @@ -46,6 +47,7 @@ jobs: fetch-depth: 0 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter + continue-on-error: true with: filters: | product: @@ -62,12 +64,28 @@ jobs: - 'biome.json' - 'vitest.config.ts' - 'tsconfig*.json' + - name: Product path decision + id: product + if: always() + run: | + set -euo pipefail + if [[ "${{ steps.filter.outcome }}" == "success" ]]; then + if [[ "${{ steps.filter.outputs.product }}" == "true" ]]; then + echo "product=true" >> "$GITHUB_OUTPUT" + else + echo "product=false" >> "$GITHUB_OUTPUT" + fi + else + echo "Change detection failed; running full Product CI." >&2 + echo "product=true" >> "$GITHUB_OUTPUT" + fi lint: name: Lint needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} runs-on: [self-hosted, evoo-ci] + timeout-minutes: 20 steps: - uses: actions/checkout@v5 with: @@ -101,8 +119,9 @@ jobs: check: name: Typecheck needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} runs-on: [self-hosted, evoo-ci] + timeout-minutes: 20 steps: - uses: actions/checkout@v5 with: @@ -139,7 +158,7 @@ jobs: build: name: Production builds needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 10 steps: @@ -181,8 +200,9 @@ jobs: test: name: Unit tests needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} runs-on: [self-hosted, evoo-ci, Linux] + timeout-minutes: 45 steps: - uses: actions/checkout@v5 with: @@ -219,7 +239,7 @@ jobs: test-integration: name: Postgres journeys needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 10 steps: @@ -255,7 +275,7 @@ jobs: test-e2e: name: Web E2E needs: changes - if: needs.changes.outputs.product == 'true' + if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }} uses: ./.github/workflows/playwright.yml with: hand_off_report: true @@ -265,9 +285,11 @@ jobs: if: always() needs: [changes, lint, check, build, test, test-integration, test-e2e] runs-on: [self-hosted, evoo-ci] + timeout-minutes: 5 steps: - name: Verify product checks env: + CHANGES: ${{ needs.changes.result }} PRODUCT: ${{ needs.changes.outputs.product }} LINT: ${{ needs.lint.result }} CHECK: ${{ needs.check.result }} @@ -277,7 +299,12 @@ jobs: TEST_E2E: ${{ needs.test-e2e.result }} run: | set -euo pipefail - if [[ "$PRODUCT" != "true" ]]; then + product="$PRODUCT" + if [[ "$CHANGES" != "success" ]]; then + echo "Change detection did not finish cleanly; requiring downstream product checks." >&2 + product=true + fi + if [[ "$product" != "true" ]]; then echo "No product-impacting paths changed; Product CI satisfied." exit 0 fi diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e295589..9a8f3c0 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -54,7 +54,7 @@ jobs: playwright: name: Web E2E runs-on: [self-hosted, evoo-ci, Linux] - timeout-minutes: 30 + timeout-minutes: 60 concurrency: group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }} cancel-in-progress: false diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml index 08ab8c4..b54e568 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -32,6 +32,7 @@ services: SANDBOX_COMMAND_TIMEOUT_MS: ${SANDBOX_COMMAND_TIMEOUT_MS:-300000} SANDBOX_TEAM_SCREEN_LIMIT: ${SANDBOX_TEAM_SCREEN_LIMIT:-0} SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env} + SANDBOX_GITHUB_BROKER_URL: ${SANDBOX_GITHUB_BROKER_URL:-http://host.docker.internal:7091/internal/github-broker} SANDBOX_SCREEN_NETWORK: isolated LOG_LEVEL: ${LOG_LEVEL:-info} LOG_FORMAT: ${LOG_FORMAT:-} @@ -39,6 +40,8 @@ services: AXIOM_DATASET: ${AXIOM_DATASET:-} AXIOM_EDGE: ${AXIOM_EDGE:-} AXIOM_EDGE_URL: ${AXIOM_EDGE_URL:-} + ports: + - "127.0.0.1:7091:7091" volumes: - /var/run/docker.sock:/var/run/docker.sock - ../../data:/data @@ -72,6 +75,7 @@ services: API_HOST: "0.0.0.0" DATA_DIR: /data SANDBOX_SUPERVISOR_URL: http://supervisor:7091 + SANDBOX_GITHUB_BROKER_URL: ${SANDBOX_GITHUB_BROKER_URL:-http://host.docker.internal:7091/internal/github-broker} WAKEUP_DRIVER: graphile SANDBOX_PROVIDER: docker CLOUD_AGENT_PROVIDER: ${CLOUD_AGENT_PROVIDER:-none} @@ -107,6 +111,7 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-rakazo}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:-rakazo} DATA_DIR: /data SANDBOX_SUPERVISOR_URL: http://supervisor:7091 + SANDBOX_GITHUB_BROKER_URL: ${SANDBOX_GITHUB_BROKER_URL:-http://host.docker.internal:7091/internal/github-broker} WAKEUP_DRIVER: graphile SANDBOX_PROVIDER: docker CLOUD_AGENT_PROVIDER: ${CLOUD_AGENT_PROVIDER:-none} diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index b0f0d17..6c36fdf 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -30,6 +30,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps \ bash \ git \ + gh \ + openssh-client \ curl \ chromium \ fonts-liberation \ @@ -39,15 +41,19 @@ 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 \ - && mkdir -p /etc/rakazo/fluxbox \ + && mkdir -p /etc/rakazo/fluxbox /usr/local/lib/rakazo/bin \ && printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \ && chmod +x /usr/bin/fbsetbg \ && ln -sfn /bin/true /usr/bin/xmessage ENV DISPLAY=:1 ENV HOME=/home/rakazo +ENV PATH=/usr/local/lib/rakazo/bin:/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WORKDIR /home/rakazo # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # otherwise leave these files unreadable to USER 1000 at runtime. +COPY --chmod=755 bin/rakazo-git bin/rakazo-gh /usr/local/lib/rakazo/bin/ +RUN ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/local/lib/rakazo/bin/git \ + && ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer COPY --chmod=755 control.py /usr/local/bin/rakazo-computer-control COPY --from=capture-builder /build/librakazo-xcapture.so /usr/local/lib/librakazo-xcapture.so diff --git a/infra/sandboxes/computer/bin/rakazo-gh b/infra/sandboxes/computer/bin/rakazo-gh new file mode 100644 index 0000000..263fd5b --- /dev/null +++ b/infra/sandboxes/computer/bin/rakazo-gh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +REAL_GH=/usr/bin/gh +if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then + exec "$REAL_GH" "$@" +fi +joined="$*" +if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then + exec "$REAL_GH" "$@" +fi +payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' -- "$@")" +response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/gh" \ + -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary "$payload")" +code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")" +python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response" +exit "$code" diff --git a/infra/sandboxes/computer/bin/rakazo-git b/infra/sandboxes/computer/bin/rakazo-git new file mode 100644 index 0000000..11daa33 --- /dev/null +++ b/infra/sandboxes/computer/bin/rakazo-git @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +REAL_GIT=/usr/bin/git +if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then + exec "$REAL_GIT" "$@" +fi +sub="${1:-}" +case "$sub" in + push|fetch|pull|clone|ls-remote) + payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[1:]}))' -- "$@")" + response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/git" \ + -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary "$payload")" + code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")" + python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response" + exit "$code" + ;; +esac +exec "$REAL_GIT" "$@" diff --git a/infra/sandboxes/computer/computer-image-tools.test.sh b/infra/sandboxes/computer/computer-image-tools.test.sh new file mode 100755 index 0000000..6143e7c --- /dev/null +++ b/infra/sandboxes/computer/computer-image-tools.test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${RAKAZO_COMPUTER_IMAGE:-rakazo/computer:local}" + +for tool in git gh ssh curl; do + docker run --rm "$IMAGE" bash -c "command -v $tool >/dev/null" +done + +docker run --rm "$IMAGE" bash -c 'git --version | grep -q "^git version "' +docker run --rm "$IMAGE" bash -c 'gh --version | grep -q "^gh version "' +docker run --rm "$IMAGE" bash -c 'test "$(command -v git)" = "/usr/local/lib/rakazo/bin/git"' diff --git a/infra/sandboxes/supervisor/src/computer-spec.test.ts b/infra/sandboxes/supervisor/src/computer-spec.test.ts index 8b59175..3dbd8a8 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.test.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.test.ts @@ -80,7 +80,7 @@ describe("graphical computer spec", () => { expect(JSON.stringify(options)).not.toMatch(/sleep/); expect(options.HostConfig.Binds).toEqual(["/var/rakazo/homes/abc:/home/rakazo"]); expect(options.Env).toContain( - "PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "PATH=/usr/local/lib/rakazo/bin:/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", ); expect(options.Env).toContain("NPM_CONFIG_PREFIX=/home/rakazo/.local"); expect(options.Env?.join("\n")).not.toMatch(/AXIOM_|LOG_LEVEL|LOG_FORMAT/); diff --git a/infra/sandboxes/supervisor/src/computer-spec.ts b/infra/sandboxes/supervisor/src/computer-spec.ts index 8e70f9b..952af5e 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { statSync } from "node:fs"; import path from "node:path"; import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime"; import type Docker from "dockerode"; @@ -7,6 +8,8 @@ export const COMPUTER_IMAGE = process.env.RAKAZO_COMPUTER_IMAGE ?? "rakazo/compu export const COMPUTER_UID = 1000; export const COMPUTER_GID = 1000; export const COMPUTER_USER = `${COMPUTER_UID}:${COMPUTER_GID}`; +export const COMPUTER_PATH = + "/usr/local/lib/rakazo/bin:/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export { screenPorts }; export const COMPUTER_CONTROL_PORT = 7070; @@ -214,6 +217,21 @@ export function homeVolumeMatches( ); } +/** Validates a host folder before bind-mounting it into a project computer. */ +export function assertProjectWorkspaceBindPath(hostPath: string): string { + const resolved = path.resolve(hostPath); + let stat: ReturnType; + try { + stat = statSync(resolved); + } catch { + throw new Error("Project workspace path does not exist on the host."); + } + if (!stat.isDirectory()) { + throw new Error("Project workspace path must be a directory on the host."); + } + return resolved; +} + export interface ComputerCreateInput { name: string; image: string; @@ -221,6 +239,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 +260,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, @@ -250,7 +295,10 @@ export function containerCreateOptions(input: ComputerCreateInput) { Env: [ "DISPLAY=:1", "HOME=/home/rakazo", - "PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + `PATH=${COMPUTER_PATH}`, + ...(process.env.SANDBOX_GITHUB_BROKER_URL + ? [`RAKAZO_GITHUB_BROKER_URL=${process.env.SANDBOX_GITHUB_BROKER_URL.replace(/\/$/, "")}`] + : []), "NPM_CONFIG_PREFIX=/home/rakazo/.local", "PIP_USER=1", ...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []), @@ -262,23 +310,20 @@ export function containerCreateOptions(input: ComputerCreateInput) { }, ExposedPorts: ports.ExposedPorts, HostConfig: { - ...(input.homeVolume + ...(input.homeVolume || input.projectWorkspacePath ? { Binds: undefined, - Mounts: [ - { - Type: "volume" as const, - Source: input.homeVolume.name, - Target: "/home/rakazo", - // Docker makes Labels and DriverConfig optional; dockerode's types do not. - VolumeOptions: { - NoCopy: true, - Subpath: input.homeVolume.subpath, - } as Docker.MountSettings["VolumeOptions"], - }, - ], + Mounts: volumeMounts, } - : { Binds: [`${input.homePath}:/home/rakazo`], Mounts: undefined }), + : { + Binds: [ + `${input.homePath}:/home/rakazo`, + ...(input.projectWorkspacePath + ? [`${input.projectWorkspacePath}:/home/rakazo/project`] + : []), + ], + Mounts: undefined, + }), PortBindings: ports.PortBindings, ShmSize: 256 * 1024 * 1024, CapDrop: ["ALL"], diff --git a/infra/sandboxes/supervisor/src/github-broker.test.ts b/infra/sandboxes/supervisor/src/github-broker.test.ts new file mode 100644 index 0000000..b5f3a71 --- /dev/null +++ b/infra/sandboxes/supervisor/src/github-broker.test.ts @@ -0,0 +1,22 @@ +import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; +import { describe, expect, it } from "vitest"; +import { githubBrokerSessionsForTests } from "./github-broker.js"; +import { hasValidBearerToken } from "./supervisor-logic.js"; + +describe("github broker sessions", () => { + it("stores sessions for supervisor-authenticated registration", () => { + const token = "supervisor-test-token"; + expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true); + githubBrokerSessionsForTests().set("run-a", { + runId: "run-a", + workspacePath: "/tmp/project", + allowedRemoteUrl: "https://github.com/example/demo.git", + defaultBranch: "main", + githubToken: "ghp_test", + pushApproved: false, + }); + const runToken = signGithubBrokerRunToken("run-a", token); + expect(runToken.startsWith("rgb1.")).toBe(true); + githubBrokerSessionsForTests().delete("run-a"); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.ts b/infra/sandboxes/supervisor/src/github-broker.ts new file mode 100644 index 0000000..7e462e1 --- /dev/null +++ b/infra/sandboxes/supervisor/src/github-broker.ts @@ -0,0 +1,210 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { + evaluateGitShellPolicy, + ghBrokerArgv, + gitNetworkArgv, + gitRemoteMatchesAllowlist, + resolveSupervisorToken, +} from "@rakazo/core"; +import { verifyGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { hasValidBearerToken } from "./supervisor-logic.js"; + +export type GithubBrokerSession = { + runId: string; + workspacePath: string; + allowedRemoteUrl: string | null; + defaultBranch: string | null; + githubToken: string; + pushApproved: boolean; +}; + +const sessions = new Map(); + +export function githubBrokerSessionsForTests() { + return sessions; +} + +function brokerSecret(): string { + return resolveSupervisorToken(process.env); +} + +function runIdFromRunToken(authorization: string | undefined): string | null { + if (!authorization?.startsWith("Bearer ")) return null; + const token = authorization.slice("Bearer ".length).trim(); + for (const runId of sessions.keys()) { + if (verifyGithubBrokerRunToken(token, brokerSecret(), runId)) return runId; + } + return null; +} + +function sessionForRequest(authorization: string | undefined): GithubBrokerSession | null { + const runId = runIdFromRunToken(authorization); + if (!runId) return null; + return sessions.get(runId) ?? null; +} + +async function runHostGit( + session: GithubBrokerSession, + argv: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const child = spawn(argv[0]!, argv.slice(1), { + cwd: session.workspacePath, + env: { + ...process.env, + HOME: session.workspacePath, + GIT_TERMINAL_PROMPT: "0", + GH_TOKEN: session.githubToken, + GITHUB_TOKEN: session.githubToken, + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 })); + child.on("error", (error) => resolve({ stdout: "", stderr: error.message, code: 1 })); + }); +} + +async function readOriginRemote(session: GithubBrokerSession): Promise { + const result = await runHostGit(session, ["/usr/bin/git", "remote", "get-url", "origin"]); + if (result.code !== 0) return null; + return result.stdout.trim() || null; +} + +async function assertRemoteAllowed(session: GithubBrokerSession, argv: string[]) { + if (!session.allowedRemoteUrl) return; + const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-")); + const candidate = remoteArg ?? (await readOriginRemote(session)); + if (candidate && !gitRemoteMatchesAllowlist(candidate, session.allowedRemoteUrl)) { + throw new Error("Push and fetch are limited to this space's linked repository remote."); + } +} + +async function resolveDefaultBranch(session: GithubBrokerSession): Promise { + if (session.defaultBranch) return session.defaultBranch; + const sym = await runHostGit(session, [ + "/usr/bin/git", + "symbolic-ref", + "refs/remotes/origin/HEAD", + ]); + if (sym.code !== 0) return null; + const ref = sym.stdout.trim(); + const match = /^refs\/remotes\/origin\/(.+)$/.exec(ref); + return match?.[1]?.trim() || null; +} + +export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) { + app.use("/internal/github-broker/*", async (c, next) => { + const pathname = c.req.path; + if (pathname.startsWith("/internal/github-broker/sessions")) { + if (!hasValidBearerToken(c.req.header("authorization"), supervisorToken)) { + return c.json({ error: "unauthorized" }, 401); + } + await next(); + return; + } + if (!sessionForRequest(c.req.header("authorization"))) { + return c.json({ error: "unauthorized" }, 401); + } + await next(); + }); + + app.post("/internal/github-broker/sessions", async (c) => { + const body = z + .object({ + runId: z.string().min(1), + workspacePath: z.string().min(1), + allowedRemoteUrl: z.string().nullable(), + defaultBranch: z.string().nullable(), + githubToken: z.string().min(1), + pushApproved: z.boolean().optional(), + }) + .parse(await c.req.json()); + const workspacePath = path.resolve(body.workspacePath); + sessions.set(body.runId, { + runId: body.runId, + workspacePath, + allowedRemoteUrl: body.allowedRemoteUrl, + defaultBranch: body.defaultBranch, + githubToken: body.githubToken, + pushApproved: body.pushApproved ?? false, + }); + return c.json({ ok: true }); + }); + + app.patch("/internal/github-broker/sessions/:runId", async (c) => { + const runId = c.req.param("runId"); + const body = z.object({ pushApproved: z.boolean() }).parse(await c.req.json()); + const session = sessions.get(runId); + if (!session) return c.json({ error: "not found" }, 404); + session.pushApproved = body.pushApproved; + return c.json({ ok: true }); + }); + + app.delete("/internal/github-broker/sessions/:runId", async (c) => { + sessions.delete(c.req.param("runId")); + return c.json({ ok: true }); + }); + + app.post("/internal/github-broker/v1/git", async (c) => { + const session = sessionForRequest(c.req.header("authorization")); + if (!session) return c.json({ error: "unauthorized" }, 401); + const body = z.object({ argv: z.array(z.string()).min(1) }).parse(await c.req.json()); + const argv = body.argv[0] === "git" ? body.argv : ["git", ...body.argv]; + if (!gitNetworkArgv(argv)) { + return c.json({ error: "unsupported git invocation" }, 400); + } + const command = argv.join(" "); + const defaultBranch = await resolveDefaultBranch(session); + const policy = evaluateGitShellPolicy({ + command, + defaultBranch, + gitPushApprovedThisRun: session.pushApproved, + }); + if (policy.action === "block") { + return c.json({ stdout: "", stderr: policy.reason, code: 1 }); + } + if (policy.action === "require_approval") { + return c.json({ stdout: "", stderr: policy.reason, code: 1 }); + } + try { + await assertRemoteAllowed(session, argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return c.json({ stdout: "", stderr: message, code: 1 }); + } + const result = await runHostGit(session, ["/usr/bin/git", ...argv.slice(1)]); + return c.json(result); + }); + + app.post("/internal/github-broker/v1/gh", async (c) => { + const session = sessionForRequest(c.req.header("authorization")); + if (!session) return c.json({ error: "unauthorized" }, 401); + const body = z.object({ argv: z.array(z.string()).min(1) }).parse(await c.req.json()); + const argv = body.argv[0] === "gh" ? body.argv : ["gh", ...body.argv]; + if (!ghBrokerArgv(argv)) { + return c.json({ error: "unsupported gh invocation" }, 400); + } + const defaultBranch = await resolveDefaultBranch(session); + const policy = evaluateGitShellPolicy({ + command: argv.join(" "), + defaultBranch, + gitPushApprovedThisRun: session.pushApproved, + }); + if (policy.action === "block" || policy.action === "require_approval") { + const reason = policy.reason; + return c.json({ stdout: "", stderr: reason, code: 1 }); + } + const result = await runHostGit(session, ["/usr/bin/gh", ...argv.slice(1)]); + return c.json(result); + }); +} diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index 765645e..ca95d82 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -22,6 +22,7 @@ import { Hono, type MiddlewareHandler } from "hono"; import { bodyLimit } from "hono/body-limit"; import { z } from "zod"; import { + assertProjectWorkspaceBindPath, assertVolumeSubpathSupport, COMPUTER_GID, COMPUTER_IMAGE, @@ -49,6 +50,7 @@ import { screenUrlWithToken, xdotoolCommand, } from "./computer-spec.js"; +import { mountGithubBrokerRoutes } from "./github-broker.js"; import { assertComputerHomeWritable } from "./home-ownership.js"; import { assertRequestIdentity, @@ -132,6 +134,8 @@ const app = new Hono(); export { app as supervisorApp }; +mountGithubBrokerRoutes(app, supervisorToken); + app.use("*", requestLogging()); export function resolveDockerSocketPath( @@ -167,6 +171,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 { @@ -244,6 +249,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); @@ -259,6 +267,7 @@ app.post("/computers", async (c) => { botId: body.botId, spaceId: body.spaceId, ...storage, + ...(projectWorkspacePath ? { projectWorkspacePath } : {}), user: computerUser, networkMode, controlToken: randomUUID(), diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh index 4a8b6b8..023ef65 100755 --- a/ops/evo-deploy/deploy.sh +++ b/ops/evo-deploy/deploy.sh @@ -455,6 +455,31 @@ rebuild_computer_image() { log "WARNING: the computer image rebuild failed; run pnpm sandbox:build" } +# Idle computers keep their old container until reprovisioned; drop providerRef so the next +# boot picks up the refreshed rakazo/computer image and broker shims without manual steps. +recreate_idle_computers() { + local previous_sha="$1" target_sha="$2" + git -C "$EVO_DEPLOY_DIR" diff --quiet "$previous_sha" "$target_sha" -- \ + infra/sandboxes/computer \ + infra/sandboxes/supervisor \ + packages/adapters/src/github-broker-client.ts \ + packages/adapters/src/deployment-github-token.ts && return 0 + if [[ "$DRY_RUN" == true ]]; then + log "[dry-run] would clear providerRef on stopped computers" + return 0 + fi + log "recreating idle computers on next boot" + run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/db exec prisma db execute --stdin" <<'SQL' >&2 || \ + log "WARNING: could not mark idle computers for recreation; restart stopped computers manually" +UPDATE "computers" +SET "providerRef" = NULL, "updatedAt" = NOW() +WHERE "state" = 'stopped' + AND "providerRef" IS NOT NULL + AND "maintenanceId" IS NULL + AND "executionRunId" IS NULL; +SQL +} + main() { parse_args "$@" validate_deploy_dir @@ -484,6 +509,7 @@ main() { write_state "$previous_sha" "$target_sha" "success" rebuild_computer_image "$previous_sha" "$target_sha" + recreate_idle_computers "$previous_sha" "$target_sha" log "deploy succeeded: $previous_sha -> $target_sha" } diff --git a/packages/adapter-kit/src/interfaces.ts b/packages/adapter-kit/src/interfaces.ts index 3968d14..56b9103 100644 --- a/packages/adapter-kit/src/interfaces.ts +++ b/packages/adapter-kit/src/interfaces.ts @@ -94,6 +94,8 @@ export interface SandboxProvider { request: { botId: string; homePath: string; + /** Host folder bound at /home/rakazo/project for owner project spaces. */ + projectWorkspacePath?: string; providerRef?: string; providerKind?: ComputerRef["kind"]; }, diff --git a/packages/adapters/src/computer-lifecycle.ts b/packages/adapters/src/computer-lifecycle.ts index d236fbb..812875e 100644 --- a/packages/adapters/src/computer-lifecycle.ts +++ b/packages/adapters/src/computer-lifecycle.ts @@ -26,6 +26,7 @@ import { restoreComputerWorkspace, } from "./computer-workspace.js"; import { resolveAgentHomePath } from "./home.js"; +import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js"; type ComputerUpdateProgress = ( stage: Exclude, @@ -280,10 +281,20 @@ export async function provisionComputer( let provisioned: ComputerRef | undefined; try { await onProgress?.("recreating"); + let projectWorkspacePath: string | undefined; + try { + projectWorkspacePath = await resolveProjectWorkspaceBind(deps.prisma, { + spaceId: context.spaceId, + userId: context.userId, + }); + } catch { + projectWorkspacePath = undefined; + } const ref = await deps.sandbox.provision( { botId: existing.homeKey, homePath, + projectWorkspacePath, providerRef: existing.providerRef ?? undefined, providerKind: existing.kind as ComputerRef["kind"], }, diff --git a/packages/adapters/src/continue-run-test-stubs.ts b/packages/adapters/src/continue-run-test-stubs.ts new file mode 100644 index 0000000..148bf74 --- /dev/null +++ b/packages/adapters/src/continue-run-test-stubs.ts @@ -0,0 +1,51 @@ +import type { AgentHomeStore } from "@rakazo/adapter-kit"; +import { vi } from "vitest"; +import type { EncryptedSecretStore } from "./secrets.js"; + +/** Prisma models queried during continueRun setup after GitHub broker integration. */ +export function stubContinueRunPrisma(prisma: T): T { + const models = prisma as Record>; + const ensure = (name: string) => { + models[name] ??= {}; + return models[name]; + }; + + const space = ensure("space"); + space.findUnique ??= vi.fn(async () => null); + + const capabilityInstall = ensure("capabilityInstall"); + capabilityInstall.findMany ??= vi.fn(async () => []); + + const secret = ensure("secret"); + secret.findFirst ??= vi.fn(async () => null); + + const attempt = ensure("attempt"); + attempt.update ??= vi.fn(async () => ({})); + + return prisma; +} + +export function minimalContinueRunHomeStub(): AgentHomeStore { + return { + pathFor: (homeKey: string) => `/tmp/rakazo-homes/${homeKey}`, + } as unknown as AgentHomeStore; +} + +export function minimalContinueRunSecretStore(): EncryptedSecretStore { + return { + load: vi.fn(() => ""), + put: vi.fn(async () => ({ id: "secret-stub" })), + } as unknown as EncryptedSecretStore; +} + +/** Default home/secretStore/prisma stubs for continueRun integration tests. */ +export function withContinueRunTestDeps( + deps: Record & { prisma: object }, +): Record { + stubContinueRunPrisma(deps.prisma); + return { + home: minimalContinueRunHomeStub(), + secretStore: minimalContinueRunSecretStore(), + ...deps, + }; +} diff --git a/packages/adapters/src/deployment-github-token.test.ts b/packages/adapters/src/deployment-github-token.test.ts new file mode 100644 index 0000000..c4adb01 --- /dev/null +++ b/packages/adapters/src/deployment-github-token.test.ts @@ -0,0 +1,47 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import { + githubIntegrationEnvironmentForRun, + githubIntegrationTokenForBroker, +} from "./deployment-github-token.js"; + +describe("githubIntegrationEnvironmentForRun", () => { + it("returns broker env without GitHub tokens when an integration token exists", () => { + vi.stubEnv("SANDBOX_GITHUB_BROKER_URL", "http://supervisor:7091/internal/github-broker"); + const env = githubIntegrationEnvironmentForRun({ + runId: "run-1", + githubToken: "ghp_test", + }); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.GH_TOKEN).toBeUndefined(); + expect(env.RAKAZO_GITHUB_BROKER_URL).toBe("http://supervisor:7091/internal/github-broker"); + expect(env.RAKAZO_GITHUB_BROKER_RUN_TOKEN).toMatch(/^rgb1\./); + expect(githubIntegrationEnvironmentForRun({ runId: "run-1", githubToken: undefined })).toEqual( + {}, + ); + vi.unstubAllEnvs(); + }); +}); + +describe("githubIntegrationTokenForBroker", () => { + it("loads the GitHub integration secret for the user", async () => { + const secrets = { load: vi.fn(() => "ghp_integration") }; + const prisma = { + capabilityInstall: { + findMany: vi.fn(async () => [ + { + kind: "mcp", + config: { preset: "github" }, + secretId: "sec-1", + }, + ]), + }, + secret: { + findFirst: vi.fn(async () => ({ id: "sec-1", ciphertext: "cipher" })), + }, + } as unknown as PrismaClient; + await expect(githubIntegrationTokenForBroker(prisma, secrets, "user-1")).resolves.toBe( + "ghp_integration", + ); + }); +}); diff --git a/packages/adapters/src/deployment-github-token.ts b/packages/adapters/src/deployment-github-token.ts new file mode 100644 index 0000000..9233359 --- /dev/null +++ b/packages/adapters/src/deployment-github-token.ts @@ -0,0 +1,48 @@ +import { isGithubInstall } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { githubBrokerEnvironmentForRun } from "./github-broker-client.js"; + +type SecretLoader = { + load(ciphertext: string, recordId: string): string; +}; + +/** GitHub PAT from Settings → Integrations (MCP preset=github), not deployment secrets. */ +export async function githubIntegrationTokenForBroker( + prisma: PrismaClient, + secrets: SecretLoader, + userId: string, + settings?: { + ownerUserId?: string | null; + ownerGithubTokenCipher?: string | null; + } | null, +): Promise { + const installs = await prisma.capabilityInstall.findMany({ + where: { userId, kind: "mcp" }, + orderBy: { createdAt: "asc" }, + }); + for (const install of installs) { + const config = install.config as Record; + if (!isGithubInstall({ kind: install.kind, config })) continue; + if (!install.secretId) continue; + const row = await prisma.secret.findFirst({ + where: { id: install.secretId, userId }, + }); + if (!row) continue; + const token = secrets.load(row.ciphertext, row.id).trim(); + if (token) return token; + } + if (settings?.ownerGithubTokenCipher && settings.ownerUserId === userId) { + const legacy = secrets.load(settings.ownerGithubTokenCipher, "deployment-github-token").trim(); + if (legacy) return legacy; + } + return undefined; +} + +/** Broker env for computer runs — never injects GITHUB_TOKEN/GH_TOKEN into the sandbox. */ +export function githubIntegrationEnvironmentForRun(input: { + runId: string; + githubToken: string | undefined; +}): Record { + if (!input.githubToken) return {}; + return githubBrokerEnvironmentForRun(input.runId); +} diff --git a/packages/adapters/src/docker-sandbox.ts b/packages/adapters/src/docker-sandbox.ts index 93cf8a9..57e3fed 100644 --- a/packages/adapters/src/docker-sandbox.ts +++ b/packages/adapters/src/docker-sandbox.ts @@ -137,7 +137,7 @@ export class DockerSandboxProvider implements SandboxProvider { } async provision( - request: { botId: string; homePath: string }, + request: { botId: string; homePath: string; projectWorkspacePath?: string }, context: AdapterContext, ): Promise { const res = await fetch(this.url("/computers"), { @@ -147,6 +147,9 @@ export class DockerSandboxProvider implements SandboxProvider { botId: request.botId, homePath: request.homePath, spaceId: context.spaceId, + ...(request.projectWorkspacePath + ? { projectWorkspacePath: request.projectWorkspacePath } + : {}), }), signal: context.signal, }); diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts index 7075310..c4ad08b 100644 --- a/packages/adapters/src/executor-capabilities.test.ts +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -1,6 +1,7 @@ import type { AgentRunRequest } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { withContinueRunTestDeps } from "./continue-run-test-stubs.js"; import { createRunExecutor, MAIN_ONLY_BUILTIN_TOOLS } from "./executor.js"; vi.mock("./computer-lifecycle.js", async (importOriginal) => ({ @@ -126,28 +127,30 @@ async function runBot( const results: unknown[] = []; const jobs = { enqueue: vi.fn(async () => undefined) }; const executor = createRunExecutor({ - prisma, - runtime: { - describe: () => ({ capabilities: { scripted: false } }), - run: async function* (next: AgentRunRequest) { - request = next; - for (const [index, call] of calls.entries()) { - results.push(await next.executeTool!(call.name, call.args ?? {}, `call-${index}`)); - } - yield { type: "done" as const, text: "Done" }; + ...withContinueRunTestDeps({ + prisma, + runtime: { + describe: () => ({ capabilities: { scripted: false } }), + run: async function* (next: AgentRunRequest) { + request = next; + for (const [index, call] of calls.entries()) { + results.push(await next.executeTool!(call.name, call.args ?? {}, `call-${index}`)); + } + yield { type: "done" as const, text: "Done" }; + }, }, - }, - sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, - memory: { read: async () => ({ documents: [] }) }, - memoryProviders: { resolve: async () => null }, - events: { - append: vi.fn(async () => undefined), - pauseRunForInput: vi.fn(async () => true), - finalizeRun: vi.fn(async () => ({ continuationRunId: null })), - }, - jobs, - secrets: [], - ...deps, + sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, + memory: { read: async () => ({ documents: [] }) }, + memoryProviders: { resolve: async () => null }, + events: { + append: vi.fn(async () => undefined), + pauseRunForInput: vi.fn(async () => true), + finalizeRun: vi.fn(async () => ({ continuationRunId: null })), + }, + jobs, + secrets: [], + ...deps, + }), } as unknown as Parameters[0]); await executor.continueRun(run.id, "worker-1"); if (!request) throw new Error("the runtime was never started"); diff --git a/packages/adapters/src/executor-computer.ts b/packages/adapters/src/executor-computer.ts index 1f52833..58bba15 100644 --- a/packages/adapters/src/executor-computer.ts +++ b/packages/adapters/src/executor-computer.ts @@ -1,4 +1,4 @@ -import type { NotificationMessage, NotificationProvider } from "@rakazo/adapter-kit"; +import type { NotificationMessage } from "@rakazo/adapter-kit"; import { runContinueJob } from "@rakazo/adapter-kit"; import type { MessageBlock } from "@rakazo/contracts"; import { redactSecrets } from "@rakazo/core"; diff --git a/packages/adapters/src/executor-effect-idempotency.test.ts b/packages/adapters/src/executor-effect-idempotency.test.ts index 9901102..9ceabc1 100644 --- a/packages/adapters/src/executor-effect-idempotency.test.ts +++ b/packages/adapters/src/executor-effect-idempotency.test.ts @@ -6,6 +6,7 @@ import { import { describe, expect, it, vi } from "vitest"; import type * as AutoReviewModule from "./auto-review.js"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { withContinueRunTestDeps } from "./continue-run-test-stubs.js"; import { createRunExecutor } from "./executor.js"; vi.mock("./computer-lifecycle.js", async (importOriginal) => ({ @@ -194,25 +195,27 @@ function fixture(runId = "run-1") { yield { type: "done" as const, text: "Done" }; }); const executor = createRunExecutor({ - prisma, - runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun }, - connector: { - discoverTools: async () => [], - resolveCall: async () => undefined, - execute: async function* () {}, - }, - sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, - memory: { - describe: () => ({ capabilities: {} }), - read: async () => ({ documents: [] }), - search: async () => [], - commit: memoryCommit, - exportMarkdown: async function* () {}, - }, - memoryProviders: { resolve: async () => null }, - events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun }, - jobs: { enqueue: vi.fn(async () => undefined) }, - secrets: [], + ...withContinueRunTestDeps({ + prisma, + runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun }, + connector: { + discoverTools: async () => [], + resolveCall: async () => undefined, + execute: async function* () {}, + }, + sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, + memory: { + describe: () => ({ capabilities: {} }), + read: async () => ({ documents: [] }), + search: async () => [], + commit: memoryCommit, + exportMarkdown: async function* () {}, + }, + memoryProviders: { resolve: async () => null }, + events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun }, + jobs: { enqueue: vi.fn(async () => undefined) }, + secrets: [], + }), } as unknown as Parameters[0]); return { diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 7fce199..a0ce4a8 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -3,6 +3,7 @@ import type { MessageBlock } from "@rakazo/contracts"; import { addDays, localDateIn } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { withContinueRunTestDeps } from "./continue-run-test-stubs.js"; import { createRunExecutor } from "./executor.js"; vi.mock("./computer-lifecycle.js", async (importOriginal) => ({ @@ -166,29 +167,31 @@ async function runOnce(options: FixtureOptions = {}) { abort: vi.fn(async () => undefined), }; const executor = createRunExecutor({ - prisma, - runtime, - connector: { - discoverTools: async () => [], - resolveCall: async () => undefined, - execute: async function* () {}, - }, - sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, - memory: { - describe: () => ({ capabilities: {} }), - read: async () => ({ documents: [] }), - search: async () => [], - commit: vi.fn(async () => ({ revision: "rev-1" })), - exportMarkdown: async function* () {}, - }, - memoryProviders: { resolve: async () => null }, - events: { - append: vi.fn(async () => undefined), - pauseRunForInput: vi.fn(async () => true), - finalizeRun: vi.fn(async () => ({ continuationRunId: null })), - }, - jobs: { enqueue: vi.fn(async () => undefined) }, - secrets: [], + ...withContinueRunTestDeps({ + prisma, + runtime, + connector: { + discoverTools: async () => [], + resolveCall: async () => undefined, + execute: async function* () {}, + }, + sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, + memory: { + describe: () => ({ capabilities: {} }), + read: async () => ({ documents: [] }), + search: async () => [], + commit: vi.fn(async () => ({ revision: "rev-1" })), + exportMarkdown: async function* () {}, + }, + memoryProviders: { resolve: async () => null }, + events: { + append: vi.fn(async () => undefined), + pauseRunForInput: vi.fn(async () => true), + finalizeRun: vi.fn(async () => ({ continuationRunId: null })), + }, + jobs: { enqueue: vi.fn(async () => undefined) }, + secrets: [], + }), } as unknown as Parameters[0]); await executor.continueRun(run.id, "worker-1"); diff --git a/packages/adapters/src/executor-lease-poll.test.ts b/packages/adapters/src/executor-lease-poll.test.ts index 7c4290a..b0af078 100644 --- a/packages/adapters/src/executor-lease-poll.test.ts +++ b/packages/adapters/src/executor-lease-poll.test.ts @@ -2,6 +2,11 @@ import type { AdapterContext, AgentRunRequest } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; import type * as AutoReviewModule from "./auto-review.js"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { + minimalContinueRunHomeStub, + minimalContinueRunSecretStore, + stubContinueRunPrisma, +} from "./continue-run-test-stubs.js"; import { createRunExecutor } from "./executor.js"; vi.mock("./computer-lifecycle.js", async (importOriginal) => ({ @@ -126,9 +131,12 @@ function executorFixture() { findUnique: vi.fn(async () => null), }, }; + stubContinueRunPrisma(prisma); const { runtime, runtimeRun, runtimeAbort, blocked } = silentBlockingRuntime(); const executor = createRunExecutor({ prisma, + home: minimalContinueRunHomeStub(), + secretStore: minimalContinueRunSecretStore(), runtime, connector: { discoverTools: async () => [], diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 87c031a..d5ee507 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { formatBotIdentity, formatConnectionsInstruction, + formatGithubGitInstruction, formatMainAgentRole, formatSubagentContext, formatUserContext, @@ -212,6 +213,12 @@ describe("formatConnectionsInstruction", () => { ); }); + it("tells the agent to use brokered git when GitHub is connected", () => { + const line = formatGithubGitInstruction(["GitHub"]); + expect(line).toMatch(/Clone private repositories with git/); + expect(line).toMatch(/Never ask the user for a personal access token/); + }); + it("says plainly when nothing is connected", () => { expect(formatConnectionsInstruction({ apps: [], tools: [] })).toBe( "No apps are connected yet. The user connects them under Settings, Integrations.", diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 5e5b8d6..2cdf69a 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -74,9 +74,16 @@ export const ROUTINE_RUN_INSTRUCTION = * neither denies a connected app nor claims one whose tools failed to load. Apps reached * through a lazy catalog come with the search tool that lists their tools. */ +export function formatGithubGitInstruction(connectedApps: readonly string[]): string | undefined { + const hasGithub = connectedApps.some((app) => app.trim().toLowerCase() === "github"); + if (!hasGithub) return undefined; + return "GitHub is connected. Clone private repositories with git on your computer (authentication is brokered) or use the GitHub integration tools for issues, pull requests, and repository APIs. Never ask the user for a personal access token, password, or SSH key for GitHub."; +} + export function formatConnectionsInstruction(input: { apps: readonly string[]; tools: readonly ConnectorTool[]; + githubUnreachableHint?: string; }): string { const names = new Set(input.apps.map((app) => app.trim()).filter(Boolean)); const catalogs: string[] = []; @@ -96,8 +103,11 @@ export function formatConnectionsInstruction(input: { } return [ `Connected apps: ${[...names].map(escapePromptData).join(", ")}. Use their tools for that app's data and actions (repositories, issues, mail, calendar and similar) before the computer browser or web search. Never tell the user one of these is not connected, and never ask them for a token, key or password for it: its sign-in is already set up.`, + input.githubUnreachableHint, ...catalogs, - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); } /** diff --git a/packages/adapters/src/executor-readonly-approval.test.ts b/packages/adapters/src/executor-readonly-approval.test.ts index 5a1b0ab..a553916 100644 --- a/packages/adapters/src/executor-readonly-approval.test.ts +++ b/packages/adapters/src/executor-readonly-approval.test.ts @@ -6,6 +6,7 @@ import { isApprovalPausedResult } from "./approval-effect.js"; import type * as AutoReviewModule from "./auto-review.js"; import { runAutoReviewJudge } from "./auto-review.js"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; +import { withContinueRunTestDeps } from "./continue-run-test-stubs.js"; import { createRunExecutor } from "./executor.js"; import { catalogEntries, resolveCatalogCall } from "./lazy-tool-catalog.js"; @@ -174,30 +175,32 @@ function fixture({ yield { type: "done" as const, text: "Done" }; }); const executor = createRunExecutor({ - prisma, - runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun }, - connector: { - discoverTools: async () => - catalog - ? [ - { - name: "demo_execute_tool", - description: "Execute a catalog tool", - inputSchema: { type: "object" }, - route: { connectorId: "demo", toolName: "__catalog_execute" }, - }, - ] - : [tool], - resolveCall: async (call: ConnectorCall) => - catalog ? resolveCatalogCall(call, catalogEntries([tool])) : undefined, - execute, - }, - sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, - memory: { read: async () => ({ documents: [] }) }, - memoryProviders: { resolve: async () => null }, - events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun }, - jobs: { enqueue: vi.fn(async () => undefined) }, - secrets: [], + ...withContinueRunTestDeps({ + prisma, + runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun }, + connector: { + discoverTools: async () => + catalog + ? [ + { + name: "demo_execute_tool", + description: "Execute a catalog tool", + inputSchema: { type: "object" }, + route: { connectorId: "demo", toolName: "__catalog_execute" }, + }, + ] + : [tool], + resolveCall: async (call: ConnectorCall) => + catalog ? resolveCatalogCall(call, catalogEntries([tool])) : undefined, + execute, + }, + sandbox: { describe: () => ({ capabilities: { graphical: false } }) }, + memory: { read: async () => ({ documents: [] }) }, + memoryProviders: { resolve: async () => null }, + events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun }, + jobs: { enqueue: vi.fn(async () => undefined) }, + secrets: [], + }), } as unknown as Parameters[0]); return { effects, diff --git a/packages/adapters/src/executor.test.ts b/packages/adapters/src/executor.test.ts index 72656e9..ac504d9 100644 --- a/packages/adapters/src/executor.test.ts +++ b/packages/adapters/src/executor.test.ts @@ -3,6 +3,7 @@ import { ONCE_ROUTINE_CRON } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { agentSecretScopeForRun } from "./agent-environment.js"; +import { withContinueRunTestDeps } from "./continue-run-test-stubs.js"; import { appendToolCompletionAudit, createRunExecutor, @@ -1259,16 +1260,18 @@ description: Prepare standup notes scratchpadItem: { findMany: vi.fn(async () => []) }, } as unknown as PrismaClient; const executor = createRunExecutor({ - prisma, - runtime: { - describe: () => ({ capabilities: { scripted: false } }), - run: runtimeRun, - }, - memoryProviders: { resolve: vi.fn(async () => null) }, - memory: { read: vi.fn(async () => ({ documents: [] })) }, - events: { append: vi.fn(async () => undefined), finalizeRun }, - jobs: { enqueue: vi.fn(async () => undefined) }, - secrets: [], + ...withContinueRunTestDeps({ + prisma, + runtime: { + describe: () => ({ capabilities: { scripted: false } }), + run: runtimeRun, + }, + memoryProviders: { resolve: vi.fn(async () => null) }, + memory: { read: vi.fn(async () => ({ documents: [] })) }, + events: { append: vi.fn(async () => undefined), finalizeRun }, + jobs: { enqueue: vi.fn(async () => undefined) }, + secrets: [], + }), } as unknown as Parameters[0]); await executor.continueRun("run-1", "worker-1"); @@ -1350,19 +1353,21 @@ description: Prepare standup notes scratchpadItem: { findMany: vi.fn(async () => []) }, } as unknown as PrismaClient; const executor = createRunExecutor({ - prisma, - runtime: { - describe: () => ({ capabilities: { scripted: false } }), - run: vi.fn(), - }, - memoryProviders: { resolve: vi.fn(async () => null) }, - memory: { read: vi.fn(async () => ({ documents: [] })) }, - events: { - append: vi.fn(async () => undefined), - finalizeRun: vi.fn(async () => ({ continuationRunId: null })), - }, - jobs: { enqueue: vi.fn(async () => undefined) }, - secrets: [], + ...withContinueRunTestDeps({ + prisma, + runtime: { + describe: () => ({ capabilities: { scripted: false } }), + run: vi.fn(), + }, + memoryProviders: { resolve: vi.fn(async () => null) }, + memory: { read: vi.fn(async () => ({ documents: [] })) }, + events: { + append: vi.fn(async () => undefined), + finalizeRun: vi.fn(async () => ({ continuationRunId: null })), + }, + jobs: { enqueue: vi.fn(async () => undefined) }, + secrets: [], + }), } as unknown as Parameters[0]); await executor.continueRun("run-1", "worker-1"); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 79fb866..f5f791e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -1,23 +1,14 @@ import { randomUUID } from "node:crypto"; import type { AdapterContext, - AgentHomeStore, - AgentModelOAuthCredential, AgentRunRequest, AgentRuntime, AgentToolCompletion, - ArtifactStore, - BrowserProvider, ComputerRef, ConnectorCall, - ConnectorProvider, JobPublisher, - ManagedConnectorProvider, - MemoryStore, - NotificationProvider, SandboxProvider, SemanticMemoryProvider, - WebProvider, } from "@rakazo/adapter-kit"; import { ArtifactTooLargeError, @@ -34,12 +25,10 @@ import { BOT_TITLE_MAX_LENGTH, BotSecretName, BotSecretSubmission, - isAttachmentImageMimeType, MediaLibraryImportInputSchema, MediaLibrarySearchInputSchema, type MessageBlock, MessageBlock as MessageBlockSchema, - OPENAI_COMPATIBLE_PROVIDER_ID, } from "@rakazo/contracts"; import type { ShowCardArgs } from "@rakazo/core"; import { @@ -87,18 +76,10 @@ import { unattendedTriggerToolRequiresApproval, userTurnBlocksForRun, } from "@rakazo/core"; -import { - approvalEffectKey, - isToolEffectIdempotencyKey, - legacyScopedToolEffectIdempotencyKey, - stableJsonValue, - toolEffectIdempotencyKey, -} from "@rakazo/core/node/approval-effect-key"; +import { approvalEffectKey, toolEffectIdempotencyKey } from "@rakazo/core/node/approval-effect-key"; import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db"; import { - appendEventInTransaction, createSpaceForMember, - createThreadMessageInTransaction, effectiveMemoryScope, ensureMainConversation, findDefaultModelCredential, @@ -187,10 +168,9 @@ import { } from "./browser-tools.js"; import { builtinAgentTools } from "./builtin-tools.js"; import { archiveSpawnedBot, spawnBot } from "./child-bots.js"; -import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-factory.js"; +import { cloudAgentsEnabled } from "./cloud-agent-factory.js"; import { executeCloudAgentTool } from "./cloud-agent-service.js"; import { validCloudAgentArgs } from "./cloud-agent-tools.js"; -import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; import { executeCoachTool, isCoachToolName } from "./coach-tools.js"; import { collectLogIds, @@ -215,7 +195,6 @@ import { computerPointerPayload, computerPointerSteps, } from "./computer-pointer.js"; -import { withComputerScreenAvailability } from "./computer-screens.js"; import { assertTeamShellCommandAllowed, assertTeamWorkspacePathAllowed, @@ -237,12 +216,14 @@ import { loadDelegatedTaskInstruction, syncDelegatedTaskRun, } from "./delegated-tasks.js"; +import { + githubIntegrationEnvironmentForRun, + githubIntegrationTokenForBroker, +} from "./deployment-github-token.js"; import { resolveDeploymentModel } from "./deployment-model.js"; import { missingTurnImagesInstruction, - STEERING_ATTACHMENT_UNAVAILABLE, settleSteeringAttachmentLoads, - TURN_ATTACHMENT_UNAVAILABLE, } from "./executor-attachments.js"; import { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js"; import { @@ -254,7 +235,6 @@ import { } from "./executor-completion.js"; import { computerRetryDelay, - computerRunRequeueData, computerScreenToolResult, notifyRun, persistMessageInTransaction, @@ -262,14 +242,14 @@ import { redactBlocks, renewRunLease, requeueComputerRun, - runNotificationsEnabled, writeComputerRunRequeue, } from "./executor-computer.js"; import { completeEffect, recordEffect, uncertainEffectError } from "./executor-effects.js"; -import { deploymentKeyFor, resolveModelKey, withModelCredentialLock } from "./executor-model.js"; +import { resolveModelKey } from "./executor-model.js"; import { formatBotIdentity, formatConnectionsInstruction, + formatGithubGitInstruction, formatMainAgentRole, formatSubagentContext, formatUserContext, @@ -282,13 +262,15 @@ import { PROTECTED_SCRIPT_SCAN_BYTES, protectedScriptWorkspacePath, } from "./executor-shell-guard.js"; -import { - filterPageBrowserTools, - PAGE_BROWSER_TOOL_NAMES, - selectBuiltinToolsForRun, -} from "./executor-tools.js"; +import { PAGE_BROWSER_TOOL_NAMES, selectBuiltinToolsForRun } from "./executor-tools.js"; import type { ExecutorDeps } from "./executor-types.js"; import { loadCurrentTurnImages, selectRunConnections } from "./executor-utils.js"; +import { evaluateGitShellPolicy } from "./git-shell-policy.js"; +import { + deleteGithubBrokerSession, + registerGithubBrokerSession, + setGithubBrokerPushApproved, +} from "./github-broker-client.js"; import { handoffToGroupBot, loadGroupContext } from "./group-handoff.js"; import { COMPACTION_BATCH_SIZE, @@ -301,6 +283,8 @@ import { selectCompactedHistory, shouldEnqueueCompaction, } from "./history-compaction.js"; +import { resolveAgentHomePath } from "./home.js"; +import { githubIntegrationDiscoveryHint } from "./installed-connectors.js"; import { assertConnectorToolArgs, CATALOG_EXECUTE, @@ -319,29 +303,18 @@ import { } from "./media-library.js"; import { executeMediaTool, isMediaToolName } from "./media-tools.js"; import { loadAgentMemoryContext } from "./memory-context.js"; -import type { MemoryProviderResolver } from "./memory-provider-factory.js"; -import { selectMemoryTools } from "./memory-tools.js"; import { isCatalogModelChoice, selectConfiguredModel, validateConnectedModelChoice, } from "./model-selection.js"; import { - filterImageReturningComputerTools, IMAGE_RETURNING_COMPUTER_TOOLS, MODEL_CANNOT_SEE_MESSAGE, modelAcceptsImageInput, - modelIdSupportsImages, } from "./model-vision.js"; import { notifyUserFromTool } from "./notify-user-tool.js"; -import { toOAuthCredential } from "./pi-credentials.js"; import { LOCAL_PROVIDER_ID, localTierModels } from "./pi-local-provider.js"; -import { - parseModelSecret, - resolveModelAuth, - secretValuesToRedact, - serializeModelSecret, -} from "./pi-oauth.js"; import { assertPlotDataWithinLimits, PLOT_TOOL_GUIDE, @@ -352,12 +325,12 @@ import { searchChartCatalog, } from "./plot-tool.js"; import { formatProductSelfModificationInstruction } from "./product-self-modification.js"; +import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js"; import { tryRecoverProseHandoff, workshopFallbackAfterBlockedHandoff, } from "./prose-handoff-recovery.js"; import { datedMemoryNote, rememberFromTool } from "./remember.js"; -import type { RemoteTransportDependencies } from "./remote-mcp.js"; import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js"; import { commitConsumedRunSecret, @@ -373,8 +346,6 @@ import { withRuntimeCleanup } from "./runtime-stream.js"; import { cancelScheduleFromTool, createScheduleFromTool, - filterBuiltinToolsForRun, - filterBuiltinToolsForThread, listSchedulesFromTool, resolveScheduleOwner, } from "./schedule-tools.js"; @@ -387,7 +358,6 @@ import { updateScratchpadItemFromTool, } from "./scratchpad-tools.js"; import { inferScript, ScriptedAgentRuntime } from "./scripted-runtime.js"; -import type { EncryptedSecretStore } from "./secrets.js"; import { listAgentSkillRecords, skillCreateFromTool, @@ -407,8 +377,6 @@ import { continueRunClaimFence, DESKTOP_HELD_FOR_TAKEOVER_MESSAGE, refreshTakeoverContinuePlan, - TAKEOVER_RESUME_CHECKPOINTS, - type TakeoverResumeCheckpoint, takeoverCheckpointOf, takeoverContinuePlan, } from "./takeover-resume.js"; @@ -1173,6 +1141,8 @@ export function createRunExecutor(deps: ExecutorDeps) { agentSkills, agentSecretRows, userContext, + spaceRecord, + projectWorkspacePath, ] = await Promise.all([ deps.prisma.bot.findUniqueOrThrow({ where: { id: run.botId }, @@ -1213,8 +1183,42 @@ export function createRunExecutor(deps: ExecutorDeps) { }, }), loadUserContext(deps.prisma, { userId: run.userId, spaceId: run.spaceId }), + deps.prisma.space.findUnique({ + where: { id: run.spaceId }, + select: { isDefault: true, gitDefaultBranch: true, gitRemoteUrl: true }, + }), + resolveProjectWorkspaceBind(deps.prisma, { + spaceId: run.spaceId, + userId: run.userId, + }), ]); - const agentEnvironment = decryptAgentEnvironment(agentSecretRows, deps.secretStore); + const githubIntegrationToken = await githubIntegrationTokenForBroker( + deps.prisma, + deps.secretStore, + run.userId, + settings, + ); + const brokerWorkspacePath = + projectWorkspacePath ?? + (bot.computer?.homeKey + ? resolveAgentHomePath(deps.home, bot.computer.homeKey, deps.dataDir ?? "./data") + : undefined); + if (brokerWorkspacePath && githubIntegrationToken) { + await registerGithubBrokerSession({ + runId, + workspacePath: brokerWorkspacePath, + allowedRemoteUrl: spaceRecord?.gitRemoteUrl ?? null, + defaultBranch: spaceRecord?.gitDefaultBranch ?? null, + githubToken: githubIntegrationToken, + }).catch(() => undefined); + } + const agentEnvironment = { + ...decryptAgentEnvironment(agentSecretRows, deps.secretStore), + ...githubIntegrationEnvironmentForRun({ + runId, + githubToken: githubIntegrationToken, + }), + }; runSecrets.push(...Object.values(agentEnvironment)); const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment); const hasModelOverride = Boolean(bot.modelProvider && bot.modelId); @@ -1631,6 +1635,7 @@ export function createRunExecutor(deps: ExecutorDeps) { select: { kind: true, request: true }, }); const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects); + const gitPushApprovedRuns = new Set(); const computerInstruction = heldForTakeover ? DESKTOP_HELD_FOR_TAKEOVER_MESSAGE : graphicalToolsAllowed @@ -1643,10 +1648,14 @@ export function createRunExecutor(deps: ExecutorDeps) { cloudAgentEnabled, teamComputer: computerMode === "team", }); + const gitBrokerPolicySuffix = githubIntegrationToken + ? " Git push and opening pull requests require the user's approval once per run; never force-push or push to the default branch." + : ""; 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/.` - : "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.") + + gitBrokerPolicySuffix; let assembled = ""; let currentTextSegment = ""; @@ -2006,10 +2015,27 @@ 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), @@ -2044,8 +2070,17 @@ 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); + void setGithubBrokerPushApproved(runId, true); + } const needsApprovalEarly = plan === "ask" || plan === "judge"; // A resumed approval keeps its key even if "Always allow" changed the policy. const usesApprovalKey = @@ -3911,10 +3946,19 @@ export function createRunExecutor(deps: ExecutorDeps) { return finish({ error: `unknown tool ${name}` }); }; + const githubUnreachableHint = await githubIntegrationDiscoveryHint( + deps.prisma, + run.userId, + exposedConnectorTools, + ); const connectionsLine = formatConnectionsInstruction({ apps: connectedPlugins.map((row) => row.displayName), tools: exposedConnectorTools, + githubUnreachableHint, }); + const githubGitInstruction = formatGithubGitInstruction( + connectedPlugins.map((row) => row.displayName), + ); const taughtSkillIndex = savedSkills.slice(0, 20); const taughtSkillsLine = taughtSkillIndex.length > 0 @@ -4160,6 +4204,7 @@ export function createRunExecutor(deps: ExecutorDeps) { ? "archive_bot safely archives a bot this bot created, and only that bot. Use it when the user asks to remove that bot or when it is finished and unused. The user can restore it or permanently delete it later. confirm_name must exactly match its name." : undefined, connectionsLine, + githubGitInstruction, agentSkillsLine, taughtSkillsLine, exposedToolNames.has("render_plot") @@ -4903,6 +4948,7 @@ export function createRunExecutor(deps: ExecutorDeps) { throw new Error("Run setup failed; retrying"); } } finally { + void deleteGithubBrokerSession(runId); detachShutdown?.(); clearInterval(leasePoll); clearInterval(heartbeat); diff --git a/packages/adapters/src/git-shell-policy.test.ts b/packages/adapters/src/git-shell-policy.test.ts new file mode 100644 index 0000000..db21b2a --- /dev/null +++ b/packages/adapters/src/git-shell-policy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { evaluateGitShellPolicy, parseGitPushRef } from "./git-shell-policy.js"; + +describe("git shell policy", () => { + it("blocks force push", () => { + expect( + evaluateGitShellPolicy({ + command: "git push --force origin feature", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toMatchObject({ action: "block" }); + }); + + it("blocks push to the default branch", () => { + expect( + evaluateGitShellPolicy({ + command: "git push origin main", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toMatchObject({ action: "block" }); + }); + + it("requires approval before the first push of a run", () => { + expect( + evaluateGitShellPolicy({ + command: "git push -u origin feature/login", + defaultBranch: "main", + gitPushApprovedThisRun: false, + }), + ).toMatchObject({ action: "require_approval" }); + }); + + it("allows feature branch push after approval", () => { + expect( + evaluateGitShellPolicy({ + command: "git push -u origin feature/login", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }), + ).toEqual({ action: "allow" }); + }); + + it("parses refspec destinations", () => { + expect(parseGitPushRef("git push origin HEAD:feature/x")).toBe("feature/x"); + }); +}); diff --git a/packages/adapters/src/git-shell-policy.ts b/packages/adapters/src/git-shell-policy.ts new file mode 100644 index 0000000..ac1bb6b --- /dev/null +++ b/packages/adapters/src/git-shell-policy.ts @@ -0,0 +1,8 @@ +export { + evaluateGitShellPolicy, + type GitShellPolicyDecision, + type GitShellPolicyInput, + ghBrokerArgv, + gitNetworkArgv, + parseGitPushRef, +} from "@rakazo/core"; diff --git a/packages/adapters/src/github-broker-client.ts b/packages/adapters/src/github-broker-client.ts new file mode 100644 index 0000000..76660f4 --- /dev/null +++ b/packages/adapters/src/github-broker-client.ts @@ -0,0 +1,86 @@ +import { resolveSupervisorToken } from "@rakazo/core"; +import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; + +export type GithubBrokerSessionInput = { + runId: string; + workspacePath: string; + allowedRemoteUrl: string | null; + defaultBranch: string | null; + githubToken: string; +}; + +function supervisorBaseUrl(): string | undefined { + const raw = process.env.SANDBOX_SUPERVISOR_URL?.trim(); + return raw ? raw.replace(/\/$/, "") : undefined; +} + +function githubBrokerBaseUrl(): string | undefined { + const configured = process.env.SANDBOX_GITHUB_BROKER_URL?.trim(); + if (configured) return configured.replace(/\/$/, ""); + const supervisor = supervisorBaseUrl(); + return supervisor ? `${supervisor}/internal/github-broker` : undefined; +} + +function supervisorAuthHeaders(): Record | undefined { + const token = resolveSupervisorToken(process.env); + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; +} + +export function githubBrokerRunToken(runId: string): string { + return signGithubBrokerRunToken(runId, resolveSupervisorToken(process.env)); +} + +export function githubBrokerEnvironmentForRun(runId: string): Record { + const base = githubBrokerBaseUrl(); + if (!base) return {}; + return { + RAKAZO_GITHUB_BROKER_URL: base, + RAKAZO_GITHUB_BROKER_RUN_TOKEN: githubBrokerRunToken(runId), + }; +} + +export async function registerGithubBrokerSession(input: GithubBrokerSessionInput): Promise { + const base = supervisorBaseUrl(); + const headers = supervisorAuthHeaders(); + if (!base || !headers) return; + const res = await fetch(`${base}/internal/github-broker/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ + runId: input.runId, + workspacePath: input.workspacePath, + allowedRemoteUrl: input.allowedRemoteUrl, + defaultBranch: input.defaultBranch, + githubToken: input.githubToken, + pushApproved: false, + }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`github broker session registration failed: ${res.status} ${detail}`.trim()); + } +} + +export async function setGithubBrokerPushApproved( + runId: string, + pushApproved: boolean, +): Promise { + const base = supervisorBaseUrl(); + const headers = supervisorAuthHeaders(); + if (!base || !headers) return; + await fetch(`${base}/internal/github-broker/sessions/${encodeURIComponent(runId)}`, { + method: "PATCH", + headers, + body: JSON.stringify({ pushApproved }), + }).catch(() => undefined); +} + +export async function deleteGithubBrokerSession(runId: string): Promise { + const base = supervisorBaseUrl(); + const headers = supervisorAuthHeaders(); + if (!base || !headers) return; + await fetch(`${base}/internal/github-broker/sessions/${encodeURIComponent(runId)}`, { + method: "DELETE", + headers, + }).catch(() => undefined); +} diff --git a/packages/adapters/src/github-broker-integration.test.ts b/packages/adapters/src/github-broker-integration.test.ts new file mode 100644 index 0000000..8f797b4 --- /dev/null +++ b/packages/adapters/src/github-broker-integration.test.ts @@ -0,0 +1,36 @@ +import { evaluateGitShellPolicy } from "@rakazo/core"; +import { describe, expect, it, vi } from "vitest"; +import { githubIntegrationEnvironmentForRun } from "./deployment-github-token.js"; + +describe("home Chief GitHub broker integration", () => { + it("exposes broker env without raw GitHub tokens when integration is set", () => { + vi.stubEnv("SANDBOX_SUPERVISOR_TOKEN", "test-supervisor-token-test-32chars"); + vi.stubEnv("SANDBOX_GITHUB_BROKER_URL", "http://supervisor:7091/internal/github-broker"); + const env = githubIntegrationEnvironmentForRun({ + runId: "run-home", + githubToken: "ghp_integration_only", + }); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.GH_TOKEN).toBeUndefined(); + expect(env.RAKAZO_GITHUB_BROKER_RUN_TOKEN).toMatch(/^rgb1\./); + vi.unstubAllEnvs(); + }); + + it("refuses push to the default branch even when approval was granted", () => { + const decision = evaluateGitShellPolicy({ + command: "git push origin main", + defaultBranch: "main", + gitPushApprovedThisRun: true, + }); + expect(decision.action).toBe("block"); + }); + + it("requires approval before the first push in a run", () => { + const decision = evaluateGitShellPolicy({ + command: "git push origin feature/login", + defaultBranch: "main", + gitPushApprovedThisRun: false, + }); + expect(decision.action).toBe("require_approval"); + }); +}); diff --git a/packages/adapters/src/installed-connectors.ts b/packages/adapters/src/installed-connectors.ts index 7e73d7a..c4f3566 100644 --- a/packages/adapters/src/installed-connectors.ts +++ b/packages/adapters/src/installed-connectors.ts @@ -5,7 +5,9 @@ import type { ConnectorProvider, ConnectorTool, } from "@rakazo/adapter-kit"; +import { isGithubInstall } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; import { z } from "zod"; import { AuthSchema, @@ -201,7 +203,16 @@ export class InstalledConnectorProvider implements ConnectorProvider { }, })); } - } catch { + } catch (error) { + if (install.kind === "mcp") { + const parsed = McpConfigSchema.safeParse(install.config); + if (parsed.success && parsed.data.preset === "github") { + getLogger().warn("GitHub MCP discovery failed", { + installId: install.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } // One unavailable user-installed source must not hide the other tools. } return []; @@ -317,6 +328,27 @@ export class InstalledConnectorProvider implements ConnectorProvider { } } +export async function githubIntegrationDiscoveryHint( + prisma: PrismaClient, + userId: string, + tools: readonly ConnectorTool[], +): Promise { + const installs = await prisma.capabilityInstall.findMany({ + where: { userId, kind: "mcp" }, + select: { id: true, kind: true, config: true, secretId: true }, + }); + const githubInstall = installs.find((install) => + isGithubInstall({ kind: install.kind, config: asRecord(install.config) ?? {} }), + ); + if (!githubInstall?.secretId) return undefined; + const hasGithubTools = tools.some((tool) => { + const group = tool.route?.catalogGroup?.toLowerCase(); + return group === "github" || tool.name.toLowerCase().includes("github"); + }); + if (hasGithubTools) return undefined; + return "GitHub is connected under Integrations but its MCP tools are unreachable right now. Use git and gh on your computer (brokered auth) for repository work, or ask the user to reconnect GitHub if that fails."; +} + export async function verifyMcpInstall(input: { source: string; config: unknown; diff --git a/packages/adapters/src/project-workspace-bind.ts b/packages/adapters/src/project-workspace-bind.ts new file mode 100644 index 0000000..68f5ac0 --- /dev/null +++ b/packages/adapters/src/project-workspace-bind.ts @@ -0,0 +1,26 @@ +import type { PrismaClient } from "@rakazo/db"; +import { resolveProjectWorkspaceDirectory } from "@rakazo/db"; + +/** Resolved host folder to bind at /home/rakazo/project for the deployment owner's project spaces. */ +export async function resolveProjectWorkspaceBind( + prisma: PrismaClient, + input: { spaceId: string; userId: string }, +): Promise { + const [space, settings] = await Promise.all([ + prisma.space.findUnique({ + where: { id: input.spaceId }, + select: { workspacePath: true, isDefault: true }, + }), + prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { ownerUserId: true }, + }), + ]); + if (!space?.workspacePath || space.isDefault) return undefined; + if (settings?.ownerUserId !== input.userId) return undefined; + try { + return await resolveProjectWorkspaceDirectory(space.workspacePath); + } catch { + return undefined; + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 562dfd6..4aab7c5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,11 @@ "development": "./src/media/workflow-template.ts", "default": "./src/media/workflow-template.ts" }, + "./node/github-broker-token": { + "types": "./src/github-broker-token.ts", + "development": "./src/github-broker-token.ts", + "default": "./src/github-broker-token.ts" + }, "./node/desktop-runtime": { "types": "./src/node/desktop-runtime.ts", "development": "./src/node/desktop-runtime.ts", diff --git a/packages/core/src/git-shell-policy.ts b/packages/core/src/git-shell-policy.ts new file mode 100644 index 0000000..aefdb7d --- /dev/null +++ b/packages/core/src/git-shell-policy.ts @@ -0,0 +1,95 @@ +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); +} + +export function gitNetworkArgv(argv: string[]): boolean { + if (argv.length === 0 || argv[0] !== "git") return false; + const sub = argv[1]; + return ( + sub === "push" || sub === "fetch" || sub === "pull" || sub === "clone" || sub === "ls-remote" + ); +} + +export function ghBrokerArgv(argv: string[]): boolean { + if (argv.length === 0 || argv[0] !== "gh") return false; + const joined = argv.join(" "); + return GH_PR_RE.test(joined); +} diff --git a/packages/core/src/github-broker-token.ts b/packages/core/src/github-broker-token.ts new file mode 100644 index 0000000..c360b8b --- /dev/null +++ b/packages/core/src/github-broker-token.ts @@ -0,0 +1,18 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const PREFIX = "rgb1."; + +/** Opaque run-scoped token for the sandbox GitHub broker (not a GitHub PAT). */ +export function signGithubBrokerRunToken(runId: string, secret: string): string { + const digest = createHmac("sha256", secret).update(runId, "utf8").digest("base64url"); + return `${PREFIX}${digest}`; +} + +export function verifyGithubBrokerRunToken(token: string, secret: string, runId: string): boolean { + if (!token.startsWith(PREFIX)) return false; + const expected = signGithubBrokerRunToken(runId, secret); + const a = Buffer.from(token, "utf8"); + const b = Buffer.from(expected, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/core/src/github-remote-allowlist.ts b/packages/core/src/github-remote-allowlist.ts new file mode 100644 index 0000000..9b4e614 --- /dev/null +++ b/packages/core/src/github-remote-allowlist.ts @@ -0,0 +1,30 @@ +/** Normalize a git remote URL for allowlist comparison. */ +export function normalizeGitRemoteUrl(raw: string | null | undefined): string | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + try { + if (/^git@/i.test(trimmed)) { + const match = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(trimmed); + if (!match) return null; + return `https://${match[1]!.toLowerCase()}/${match[2]!.replace(/^\//, "")}`.replace( + /\.git$/i, + "", + ); + } + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return `${url.protocol}//${url.host.toLowerCase()}${url.pathname.replace(/\/$/, "").replace(/\.git$/i, "")}`; + } catch { + return null; + } +} + +export function gitRemoteMatchesAllowlist( + remoteUrl: string | null | undefined, + allowedRemoteUrl: string | null | undefined, +): boolean { + const remote = normalizeGitRemoteUrl(remoteUrl); + const allowed = normalizeGitRemoteUrl(allowedRemoteUrl); + if (!remote || !allowed) return false; + return remote === allowed; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e1de3fe..ac7bedc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,7 +21,9 @@ export * from "./cron.js"; export * from "./delegated-tasks.js"; export * from "./events.js"; export * from "./featured-connectors.js"; +export * from "./git-shell-policy.js"; export * from "./github-connector.js"; +export * from "./github-remote-allowlist.js"; export * from "./group-mentions.js"; export * from "./http-response.js"; export * from "./mcp.js"; diff --git a/packages/db/src/project-workspace-mount.test.ts b/packages/db/src/project-workspace-mount.test.ts new file mode 100644 index 0000000..d5d2179 --- /dev/null +++ b/packages/db/src/project-workspace-mount.test.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js"; + +describe("assertProjectWorkspaceMountAllowed", () => { + it("rejects the filesystem root and the user home directory", () => { + expect(() => assertProjectWorkspaceMountAllowed(path.parse("/").root)).toThrow(/root/); + expect(() => assertProjectWorkspaceMountAllowed(homedir())).toThrow(/home directory/); + }); +}); diff --git a/packages/db/src/project-workspace-mount.ts b/packages/db/src/project-workspace-mount.ts new file mode 100644 index 0000000..0a529fe --- /dev/null +++ b/packages/db/src/project-workspace-mount.ts @@ -0,0 +1,15 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { InvalidProjectWorkspacePathError } from "./project-workspace.js"; + +/** Host folder allowed to bind into a project computer at /home/rakazo/project. */ +export function assertProjectWorkspaceMountAllowed(resolvedDirectory: string): void { + const normalized = path.resolve(resolvedDirectory); + if (normalized === path.parse(normalized).root) { + throw new InvalidProjectWorkspacePathError("The project folder cannot be the filesystem root."); + } + const home = path.resolve(homedir()); + if (normalized === home) { + throw new InvalidProjectWorkspacePathError("The project folder cannot be your home directory."); + } +} diff --git a/packages/testkit/src/computer-image-tools.test.ts b/packages/testkit/src/computer-image-tools.test.ts new file mode 100644 index 0000000..019260f --- /dev/null +++ b/packages/testkit/src/computer-image-tools.test.ts @@ -0,0 +1,35 @@ +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 broker shims when the image is built locally", () => { + if (process.env.RAKAZO_COMPUTER_IMAGE_TOOLS_TEST !== "1") { + return; + } + const image = process.env.RAKAZO_COMPUTER_IMAGE ?? "rakazo/computer:local"; + if (!dockerImagePresent(image)) { + return; + } + execFileSync("bash", [script], { + env: { ...process.env, RAKAZO_COMPUTER_IMAGE: image }, + stdio: "inherit", + }); + expect(true).toBe(true); + }); +}); diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts index 6a7f40c..677cf4f 100644 --- a/packages/testkit/src/evo-deploy.test.ts +++ b/packages/testkit/src/evo-deploy.test.ts @@ -191,7 +191,9 @@ describe("evo deploy", () => { write(path.join(changed.checkout, "../bin/docker"), "#!/bin/bash\n", 0o755); const result = changed.run(); expect(result.status, result.stderr).toBe(0); - expect(changed.commands().at(-1)).toBe("B sandbox:build"); + const cmds = changed.commands(); + expect(cmds).toContain("B sandbox:build"); + expect(cmds.at(-1)).toBe("B --filter @rakazo/db exec prisma db execute --stdin"); }); it("stops a failing build before the restart and keeps the served build", () => {