Broker GitHub Integrations token for computer git/gh (#203)

* Broker GitHub integration token for computer git/gh

Use the Integrations GitHub MCP secret for supervisor broker sessions
so home and project computers can clone private repos without PAT prompts.
Add rakazo git/gh shims, push policy, deploy image rebuild, and tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger product checks after runner timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: run product change detection on GitHub-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format deployment-github-token

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: self-hosted change detection with timeout and fail-open gate

Keep Detect product changes on evoo-ci with a 10-minute cap, continue-on-error
filtering, and a fail-open product flag when detection fails. Downstream jobs
and Product CI treat a failed or timed-out changes job as full product impact.
Add job timeouts on remaining self-hosted ci jobs.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: continueRun tests and broker home path guard

- Skip broker workspace resolution when computer homeKey is absent
- Add shared continue-run Prisma/home/secretStore test stubs
- Update computer PATH expectation and evo-deploy command sequence
- Remove unused executor imports flagged by Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: Biome format and import order for broker CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: compose YAML, typecheck for broker stubs and GitHub hint

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger Product CI after compose and typecheck fixes

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retry Product CI queue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: give self-hosted Web E2E a 45m job timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: extend Web E2E job timeout to 60m on self-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: keep github-broker-token off the core browser barrel

Export broker HMAC helpers only from @rakazo/core/node/github-broker-token so
Vite does not pull node:crypto into the web client and break Playwright signup.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-10-11 22:58:48 +02:00 committed by GitHub
parent 22fa2e1606
commit f14b112cdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1296 additions and 197 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { statSync } from "node:fs";
import path from "node:path";
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
import type Docker from "dockerode";
@ -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<typeof statSync>;
try {
stat = statSync(resolved);
} catch {
throw new Error("Project workspace path does not exist on the host.");
}
if (!stat.isDirectory()) {
throw new Error("Project workspace path must be a directory on the host.");
}
return resolved;
}
export interface ComputerCreateInput {
name: string;
image: string;
@ -221,6 +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"],

View File

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

View File

@ -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<string, GithubBrokerSession>();
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<string | null> {
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<string | null> {
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);
});
}

View File

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

View File

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

View File

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

View File

@ -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<ComputerUpdate["stage"], "preparing">,
@ -280,10 +281,20 @@ export async function provisionComputer(
let provisioned: ComputerRef | undefined;
try {
await onProgress?.("recreating");
let projectWorkspacePath: string | undefined;
try {
projectWorkspacePath = await resolveProjectWorkspaceBind(deps.prisma, {
spaceId: context.spaceId,
userId: context.userId,
});
} catch {
projectWorkspacePath = undefined;
}
const ref = await deps.sandbox.provision(
{
botId: existing.homeKey,
homePath,
projectWorkspacePath,
providerRef: existing.providerRef ?? undefined,
providerKind: existing.kind as ComputerRef["kind"],
},

View File

@ -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<T extends object>(prisma: T): T {
const models = prisma as Record<string, Record<string, unknown>>;
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<string, unknown> & { prisma: object },
): Record<string, unknown> {
stubContinueRunPrisma(deps.prisma);
return {
home: minimalContinueRunHomeStub(),
secretStore: minimalContinueRunSecretStore(),
...deps,
};
}

View File

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

View File

@ -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<string | undefined> {
const installs = await prisma.capabilityInstall.findMany({
where: { userId, kind: "mcp" },
orderBy: { createdAt: "asc" },
});
for (const install of installs) {
const config = install.config as Record<string, unknown>;
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<string, string> {
if (!input.githubToken) return {};
return githubBrokerEnvironmentForRun(input.runId);
}

View File

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

View File

@ -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,6 +127,7 @@ async function runBot(
const results: unknown[] = [];
const jobs = { enqueue: vi.fn(async () => undefined) };
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime: {
describe: () => ({ capabilities: { scripted: false } }),
@ -148,6 +150,7 @@ async function runBot(
jobs,
secrets: [],
...deps,
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
await executor.continueRun(run.id, "worker-1");
if (!request) throw new Error("the runtime was never started");

View File

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

View File

@ -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,6 +195,7 @@ function fixture(runId = "run-1") {
yield { type: "done" as const, text: "Done" };
});
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
connector: {
@ -213,6 +215,7 @@ function fixture(runId = "run-1") {
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
jobs: { enqueue: vi.fn(async () => undefined) },
secrets: [],
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
return {

View File

@ -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,6 +167,7 @@ async function runOnce(options: FixtureOptions = {}) {
abort: vi.fn(async () => undefined),
};
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime,
connector: {
@ -189,6 +191,7 @@ async function runOnce(options: FixtureOptions = {}) {
},
jobs: { enqueue: vi.fn(async () => undefined) },
secrets: [],
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
await executor.continueRun(run.id, "worker-1");

View File

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

View File

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

View File

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

View File

@ -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,6 +175,7 @@ function fixture({
yield { type: "done" as const, text: "Done" };
});
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
connector: {
@ -198,6 +200,7 @@ function fixture({
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
jobs: { enqueue: vi.fn(async () => undefined) },
secrets: [],
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
return {
effects,

View File

@ -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,6 +1260,7 @@ description: Prepare standup notes
scratchpadItem: { findMany: vi.fn(async () => []) },
} as unknown as PrismaClient;
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime: {
describe: () => ({ capabilities: { scripted: false } }),
@ -1269,6 +1271,7 @@ description: Prepare standup notes
events: { append: vi.fn(async () => undefined), finalizeRun },
jobs: { enqueue: vi.fn(async () => undefined) },
secrets: [],
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
await executor.continueRun("run-1", "worker-1");
@ -1350,6 +1353,7 @@ description: Prepare standup notes
scratchpadItem: { findMany: vi.fn(async () => []) },
} as unknown as PrismaClient;
const executor = createRunExecutor({
...withContinueRunTestDeps({
prisma,
runtime: {
describe: () => ({ capabilities: { scripted: false } }),
@ -1363,6 +1367,7 @@ description: Prepare standup notes
},
jobs: { enqueue: vi.fn(async () => undefined) },
secrets: [],
}),
} as unknown as Parameters<typeof createRunExecutor>[0]);
await executor.continueRun("run-1", "worker-1");

View File

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

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { evaluateGitShellPolicy, parseGitPushRef } from "./git-shell-policy.js";
describe("git shell policy", () => {
it("blocks force push", () => {
expect(
evaluateGitShellPolicy({
command: "git push --force origin feature",
defaultBranch: "main",
gitPushApprovedThisRun: true,
}),
).toMatchObject({ action: "block" });
});
it("blocks push to the default branch", () => {
expect(
evaluateGitShellPolicy({
command: "git push origin main",
defaultBranch: "main",
gitPushApprovedThisRun: true,
}),
).toMatchObject({ action: "block" });
});
it("requires approval before the first push of a run", () => {
expect(
evaluateGitShellPolicy({
command: "git push -u origin feature/login",
defaultBranch: "main",
gitPushApprovedThisRun: false,
}),
).toMatchObject({ action: "require_approval" });
});
it("allows feature branch push after approval", () => {
expect(
evaluateGitShellPolicy({
command: "git push -u origin feature/login",
defaultBranch: "main",
gitPushApprovedThisRun: true,
}),
).toEqual({ action: "allow" });
});
it("parses refspec destinations", () => {
expect(parseGitPushRef("git push origin HEAD:feature/x")).toBe("feature/x");
});
});

View File

@ -0,0 +1,8 @@
export {
evaluateGitShellPolicy,
type GitShellPolicyDecision,
type GitShellPolicyInput,
ghBrokerArgv,
gitNetworkArgv,
parseGitPushRef,
} from "@rakazo/core";

View File

@ -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<string, string> | 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<string, string> {
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<void> {
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<void> {
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<void> {
const base = supervisorBaseUrl();
const headers = supervisorAuthHeaders();
if (!base || !headers) return;
await fetch(`${base}/internal/github-broker/sessions/${encodeURIComponent(runId)}`, {
method: "DELETE",
headers,
}).catch(() => undefined);
}

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import type { PrismaClient } from "@rakazo/db";
import { resolveProjectWorkspaceDirectory } from "@rakazo/db";
/** Resolved host folder to bind at /home/rakazo/project for the deployment owner's project spaces. */
export async function resolveProjectWorkspaceBind(
prisma: PrismaClient,
input: { spaceId: string; userId: string },
): Promise<string | undefined> {
const [space, settings] = await Promise.all([
prisma.space.findUnique({
where: { id: input.spaceId },
select: { workspacePath: true, isDefault: true },
}),
prisma.deploymentSettings.findUnique({
where: { id: "default" },
select: { ownerUserId: true },
}),
]);
if (!space?.workspacePath || space.isDefault) return undefined;
if (settings?.ownerUserId !== input.userId) return undefined;
try {
return await resolveProjectWorkspaceDirectory(space.workspacePath);
} catch {
return undefined;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import { homedir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js";
describe("assertProjectWorkspaceMountAllowed", () => {
it("rejects the filesystem root and the user home directory", () => {
expect(() => assertProjectWorkspaceMountAllowed(path.parse("/").root)).toThrow(/root/);
expect(() => assertProjectWorkspaceMountAllowed(homedir())).toThrow(/home directory/);
});
});

View File

@ -0,0 +1,15 @@
import { homedir } from "node:os";
import path from "node:path";
import { InvalidProjectWorkspacePathError } from "./project-workspace.js";
/** Host folder allowed to bind into a project computer at /home/rakazo/project. */
export function assertProjectWorkspaceMountAllowed(resolvedDirectory: string): void {
const normalized = path.resolve(resolvedDirectory);
if (normalized === path.parse(normalized).root) {
throw new InvalidProjectWorkspacePathError("The project folder cannot be the filesystem root.");
}
const home = path.resolve(homedir());
if (normalized === home) {
throw new InvalidProjectWorkspacePathError("The project folder cannot be your home directory.");
}
}

View File

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

View File

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