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:
parent
22fa2e1606
commit
f14b112cdd
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
@ -37,8 +37,9 @@ jobs:
|
|||||||
changes:
|
changes:
|
||||||
name: Detect product changes
|
name: Detect product changes
|
||||||
runs-on: [self-hosted, evoo-ci]
|
runs-on: [self-hosted, evoo-ci]
|
||||||
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
product: ${{ steps.filter.outputs.product }}
|
product: ${{ steps.product.outputs.product }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@ -46,6 +47,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
id: filter
|
id: filter
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
product:
|
product:
|
||||||
@ -62,12 +64,28 @@ jobs:
|
|||||||
- 'biome.json'
|
- 'biome.json'
|
||||||
- 'vitest.config.ts'
|
- 'vitest.config.ts'
|
||||||
- 'tsconfig*.json'
|
- '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:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
needs: changes
|
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]
|
runs-on: [self-hosted, evoo-ci]
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@ -101,8 +119,9 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
needs: changes
|
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]
|
runs-on: [self-hosted, evoo-ci]
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@ -139,7 +158,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: changes
|
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]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@ -181,8 +200,9 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Unit tests
|
name: Unit tests
|
||||||
needs: changes
|
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]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@ -219,7 +239,7 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
name: Postgres journeys
|
name: Postgres journeys
|
||||||
needs: changes
|
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]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@ -255,7 +275,7 @@ jobs:
|
|||||||
test-e2e:
|
test-e2e:
|
||||||
name: Web E2E
|
name: Web E2E
|
||||||
needs: changes
|
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
|
uses: ./.github/workflows/playwright.yml
|
||||||
with:
|
with:
|
||||||
hand_off_report: true
|
hand_off_report: true
|
||||||
@ -265,9 +285,11 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
needs: [changes, lint, check, build, test, test-integration, test-e2e]
|
needs: [changes, lint, check, build, test, test-integration, test-e2e]
|
||||||
runs-on: [self-hosted, evoo-ci]
|
runs-on: [self-hosted, evoo-ci]
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Verify product checks
|
- name: Verify product checks
|
||||||
env:
|
env:
|
||||||
|
CHANGES: ${{ needs.changes.result }}
|
||||||
PRODUCT: ${{ needs.changes.outputs.product }}
|
PRODUCT: ${{ needs.changes.outputs.product }}
|
||||||
LINT: ${{ needs.lint.result }}
|
LINT: ${{ needs.lint.result }}
|
||||||
CHECK: ${{ needs.check.result }}
|
CHECK: ${{ needs.check.result }}
|
||||||
@ -277,7 +299,12 @@ jobs:
|
|||||||
TEST_E2E: ${{ needs.test-e2e.result }}
|
TEST_E2E: ${{ needs.test-e2e.result }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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."
|
echo "No product-impacting paths changed; Product CI satisfied."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@ -54,7 +54,7 @@ jobs:
|
|||||||
playwright:
|
playwright:
|
||||||
name: Web E2E
|
name: Web E2E
|
||||||
runs-on: [self-hosted, evoo-ci, Linux]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|||||||
@ -32,6 +32,7 @@ services:
|
|||||||
SANDBOX_COMMAND_TIMEOUT_MS: ${SANDBOX_COMMAND_TIMEOUT_MS:-300000}
|
SANDBOX_COMMAND_TIMEOUT_MS: ${SANDBOX_COMMAND_TIMEOUT_MS:-300000}
|
||||||
SANDBOX_TEAM_SCREEN_LIMIT: ${SANDBOX_TEAM_SCREEN_LIMIT:-0}
|
SANDBOX_TEAM_SCREEN_LIMIT: ${SANDBOX_TEAM_SCREEN_LIMIT:-0}
|
||||||
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
|
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
|
SANDBOX_SCREEN_NETWORK: isolated
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
LOG_FORMAT: ${LOG_FORMAT:-}
|
LOG_FORMAT: ${LOG_FORMAT:-}
|
||||||
@ -39,6 +40,8 @@ services:
|
|||||||
AXIOM_DATASET: ${AXIOM_DATASET:-}
|
AXIOM_DATASET: ${AXIOM_DATASET:-}
|
||||||
AXIOM_EDGE: ${AXIOM_EDGE:-}
|
AXIOM_EDGE: ${AXIOM_EDGE:-}
|
||||||
AXIOM_EDGE_URL: ${AXIOM_EDGE_URL:-}
|
AXIOM_EDGE_URL: ${AXIOM_EDGE_URL:-}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:7091:7091"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ../../data:/data
|
- ../../data:/data
|
||||||
@ -72,6 +75,7 @@ services:
|
|||||||
API_HOST: "0.0.0.0"
|
API_HOST: "0.0.0.0"
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
|
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
|
WAKEUP_DRIVER: graphile
|
||||||
SANDBOX_PROVIDER: docker
|
SANDBOX_PROVIDER: docker
|
||||||
CLOUD_AGENT_PROVIDER: ${CLOUD_AGENT_PROVIDER:-none}
|
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}
|
DATABASE_URL: postgres://${POSTGRES_USER:-rakazo}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:-rakazo}
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
|
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
|
WAKEUP_DRIVER: graphile
|
||||||
SANDBOX_PROVIDER: docker
|
SANDBOX_PROVIDER: docker
|
||||||
CLOUD_AGENT_PROVIDER: ${CLOUD_AGENT_PROVIDER:-none}
|
CLOUD_AGENT_PROVIDER: ${CLOUD_AGENT_PROVIDER:-none}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
procps \
|
procps \
|
||||||
bash \
|
bash \
|
||||||
git \
|
git \
|
||||||
|
gh \
|
||||||
|
openssh-client \
|
||||||
curl \
|
curl \
|
||||||
chromium \
|
chromium \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
@ -39,15 +41,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --gid 1000 rakazo \
|
&& groupadd --gid 1000 rakazo \
|
||||||
&& useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash rakazo \
|
&& useradd --uid 1000 --gid rakazo --create-home --shell /bin/bash rakazo \
|
||||||
&& 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 \
|
&& printf '%s\n' '#!/bin/sh' 'exec xsetroot -solid "#111113" "$@"' > /usr/bin/fbsetbg \
|
||||||
&& chmod +x /usr/bin/fbsetbg \
|
&& chmod +x /usr/bin/fbsetbg \
|
||||||
&& ln -sfn /bin/true /usr/bin/xmessage
|
&& ln -sfn /bin/true /usr/bin/xmessage
|
||||||
ENV DISPLAY=:1
|
ENV DISPLAY=:1
|
||||||
ENV HOME=/home/rakazo
|
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
|
WORKDIR /home/rakazo
|
||||||
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
|
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
|
||||||
# otherwise leave these files unreadable to USER 1000 at runtime.
|
# 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 start.sh /usr/local/bin/rakazo-computer
|
||||||
COPY --chmod=755 control.py /usr/local/bin/rakazo-computer-control
|
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
|
COPY --from=capture-builder /build/librakazo-xcapture.so /usr/local/lib/librakazo-xcapture.so
|
||||||
|
|||||||
18
infra/sandboxes/computer/bin/rakazo-gh
Normal file
18
infra/sandboxes/computer/bin/rakazo-gh
Normal 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"
|
||||||
20
infra/sandboxes/computer/bin/rakazo-git
Normal file
20
infra/sandboxes/computer/bin/rakazo-git
Normal 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" "$@"
|
||||||
12
infra/sandboxes/computer/computer-image-tools.test.sh
Executable file
12
infra/sandboxes/computer/computer-image-tools.test.sh
Executable 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"'
|
||||||
@ -80,7 +80,7 @@ describe("graphical computer spec", () => {
|
|||||||
expect(JSON.stringify(options)).not.toMatch(/sleep/);
|
expect(JSON.stringify(options)).not.toMatch(/sleep/);
|
||||||
expect(options.HostConfig.Binds).toEqual(["/var/rakazo/homes/abc:/home/rakazo"]);
|
expect(options.HostConfig.Binds).toEqual(["/var/rakazo/homes/abc:/home/rakazo"]);
|
||||||
expect(options.Env).toContain(
|
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).toContain("NPM_CONFIG_PREFIX=/home/rakazo/.local");
|
||||||
expect(options.Env?.join("\n")).not.toMatch(/AXIOM_|LOG_LEVEL|LOG_FORMAT/);
|
expect(options.Env?.join("\n")).not.toMatch(/AXIOM_|LOG_LEVEL|LOG_FORMAT/);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
|
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
|
||||||
import type Docker from "dockerode";
|
import type Docker from "dockerode";
|
||||||
@ -7,6 +8,8 @@ export const COMPUTER_IMAGE = process.env.RAKAZO_COMPUTER_IMAGE ?? "rakazo/compu
|
|||||||
export const COMPUTER_UID = 1000;
|
export const COMPUTER_UID = 1000;
|
||||||
export const COMPUTER_GID = 1000;
|
export const COMPUTER_GID = 1000;
|
||||||
export const COMPUTER_USER = `${COMPUTER_UID}:${COMPUTER_GID}`;
|
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 { screenPorts };
|
||||||
export const COMPUTER_CONTROL_PORT = 7070;
|
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 {
|
export interface ComputerCreateInput {
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
@ -221,6 +239,8 @@ export interface ComputerCreateInput {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
homePath: string;
|
homePath: string;
|
||||||
homeVolume?: { name: string; subpath: string };
|
homeVolume?: { name: string; subpath: string };
|
||||||
|
/** Host path to bind read-write at /home/rakazo/project (deployment owner project spaces). */
|
||||||
|
projectWorkspacePath?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
controlToken?: string;
|
controlToken?: string;
|
||||||
networkMode?: string;
|
networkMode?: string;
|
||||||
@ -240,8 +260,33 @@ export type SandboxInput =
|
|||||||
| PointerInput
|
| PointerInput
|
||||||
| { kind: "clipboard"; text: string };
|
| { kind: "clipboard"; text: string };
|
||||||
|
|
||||||
|
function computerVolumeMounts(input: ComputerCreateInput): Docker.MountSettings[] {
|
||||||
|
const mounts: Docker.MountSettings[] = [];
|
||||||
|
if (input.homeVolume) {
|
||||||
|
mounts.push({
|
||||||
|
Type: "volume",
|
||||||
|
Source: input.homeVolume.name,
|
||||||
|
Target: "/home/rakazo",
|
||||||
|
VolumeOptions: {
|
||||||
|
NoCopy: true,
|
||||||
|
Subpath: input.homeVolume.subpath,
|
||||||
|
} as Docker.MountSettings["VolumeOptions"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.projectWorkspacePath) {
|
||||||
|
mounts.push({
|
||||||
|
Type: "bind",
|
||||||
|
Source: input.projectWorkspacePath,
|
||||||
|
Target: "/home/rakazo/project",
|
||||||
|
ReadOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mounts;
|
||||||
|
}
|
||||||
|
|
||||||
export function containerCreateOptions(input: ComputerCreateInput) {
|
export function containerCreateOptions(input: ComputerCreateInput) {
|
||||||
const ports = computerPortBindings(input.publishControlPort);
|
const ports = computerPortBindings(input.publishControlPort);
|
||||||
|
const volumeMounts = computerVolumeMounts(input);
|
||||||
return {
|
return {
|
||||||
Image: input.image,
|
Image: input.image,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@ -250,7 +295,10 @@ export function containerCreateOptions(input: ComputerCreateInput) {
|
|||||||
Env: [
|
Env: [
|
||||||
"DISPLAY=:1",
|
"DISPLAY=:1",
|
||||||
"HOME=/home/rakazo",
|
"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",
|
"NPM_CONFIG_PREFIX=/home/rakazo/.local",
|
||||||
"PIP_USER=1",
|
"PIP_USER=1",
|
||||||
...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []),
|
...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []),
|
||||||
@ -262,23 +310,20 @@ export function containerCreateOptions(input: ComputerCreateInput) {
|
|||||||
},
|
},
|
||||||
ExposedPorts: ports.ExposedPorts,
|
ExposedPorts: ports.ExposedPorts,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
...(input.homeVolume
|
...(input.homeVolume || input.projectWorkspacePath
|
||||||
? {
|
? {
|
||||||
Binds: undefined,
|
Binds: undefined,
|
||||||
Mounts: [
|
Mounts: volumeMounts,
|
||||||
{
|
|
||||||
Type: "volume" as const,
|
|
||||||
Source: input.homeVolume.name,
|
|
||||||
Target: "/home/rakazo",
|
|
||||||
// Docker makes Labels and DriverConfig optional; dockerode's types do not.
|
|
||||||
VolumeOptions: {
|
|
||||||
NoCopy: true,
|
|
||||||
Subpath: input.homeVolume.subpath,
|
|
||||||
} as Docker.MountSettings["VolumeOptions"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
: { Binds: [`${input.homePath}:/home/rakazo`], Mounts: undefined }),
|
: {
|
||||||
|
Binds: [
|
||||||
|
`${input.homePath}:/home/rakazo`,
|
||||||
|
...(input.projectWorkspacePath
|
||||||
|
? [`${input.projectWorkspacePath}:/home/rakazo/project`]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
Mounts: undefined,
|
||||||
|
}),
|
||||||
PortBindings: ports.PortBindings,
|
PortBindings: ports.PortBindings,
|
||||||
ShmSize: 256 * 1024 * 1024,
|
ShmSize: 256 * 1024 * 1024,
|
||||||
CapDrop: ["ALL"],
|
CapDrop: ["ALL"],
|
||||||
|
|||||||
22
infra/sandboxes/supervisor/src/github-broker.test.ts
Normal file
22
infra/sandboxes/supervisor/src/github-broker.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
210
infra/sandboxes/supervisor/src/github-broker.ts
Normal file
210
infra/sandboxes/supervisor/src/github-broker.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { Hono, type MiddlewareHandler } from "hono";
|
|||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
assertProjectWorkspaceBindPath,
|
||||||
assertVolumeSubpathSupport,
|
assertVolumeSubpathSupport,
|
||||||
COMPUTER_GID,
|
COMPUTER_GID,
|
||||||
COMPUTER_IMAGE,
|
COMPUTER_IMAGE,
|
||||||
@ -49,6 +50,7 @@ import {
|
|||||||
screenUrlWithToken,
|
screenUrlWithToken,
|
||||||
xdotoolCommand,
|
xdotoolCommand,
|
||||||
} from "./computer-spec.js";
|
} from "./computer-spec.js";
|
||||||
|
import { mountGithubBrokerRoutes } from "./github-broker.js";
|
||||||
import { assertComputerHomeWritable } from "./home-ownership.js";
|
import { assertComputerHomeWritable } from "./home-ownership.js";
|
||||||
import {
|
import {
|
||||||
assertRequestIdentity,
|
assertRequestIdentity,
|
||||||
@ -132,6 +134,8 @@ const app = new Hono();
|
|||||||
|
|
||||||
export { app as supervisorApp };
|
export { app as supervisorApp };
|
||||||
|
|
||||||
|
mountGithubBrokerRoutes(app, supervisorToken);
|
||||||
|
|
||||||
app.use("*", requestLogging());
|
app.use("*", requestLogging());
|
||||||
|
|
||||||
export function resolveDockerSocketPath(
|
export function resolveDockerSocketPath(
|
||||||
@ -167,6 +171,7 @@ app.post("/computers", async (c) => {
|
|||||||
botId: z.string().min(1),
|
botId: z.string().min(1),
|
||||||
homePath: z.string().min(1),
|
homePath: z.string().min(1),
|
||||||
spaceId: z.string().min(1),
|
spaceId: z.string().min(1),
|
||||||
|
projectWorkspacePath: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.parse(await c.req.json());
|
.parse(await c.req.json());
|
||||||
try {
|
try {
|
||||||
@ -244,6 +249,9 @@ app.post("/computers", async (c) => {
|
|||||||
? COMPUTER_GID
|
? COMPUTER_GID
|
||||||
: hostGid;
|
: hostGid;
|
||||||
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
|
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
|
||||||
|
const projectWorkspacePath = body.projectWorkspacePath
|
||||||
|
? assertProjectWorkspaceBindPath(body.projectWorkspacePath)
|
||||||
|
: undefined;
|
||||||
const name = containerNameFor(body.botId);
|
const name = containerNameFor(body.botId);
|
||||||
const createdNetwork =
|
const createdNetwork =
|
||||||
screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId);
|
screenNetworkMode === "internal" ? undefined : await ensureBotNetwork(body.botId);
|
||||||
@ -259,6 +267,7 @@ app.post("/computers", async (c) => {
|
|||||||
botId: body.botId,
|
botId: body.botId,
|
||||||
spaceId: body.spaceId,
|
spaceId: body.spaceId,
|
||||||
...storage,
|
...storage,
|
||||||
|
...(projectWorkspacePath ? { projectWorkspacePath } : {}),
|
||||||
user: computerUser,
|
user: computerUser,
|
||||||
networkMode,
|
networkMode,
|
||||||
controlToken: randomUUID(),
|
controlToken: randomUUID(),
|
||||||
|
|||||||
@ -455,6 +455,31 @@ rebuild_computer_image() {
|
|||||||
log "WARNING: the computer image rebuild failed; run pnpm sandbox:build"
|
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() {
|
main() {
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
validate_deploy_dir
|
validate_deploy_dir
|
||||||
@ -484,6 +509,7 @@ main() {
|
|||||||
|
|
||||||
write_state "$previous_sha" "$target_sha" "success"
|
write_state "$previous_sha" "$target_sha" "success"
|
||||||
rebuild_computer_image "$previous_sha" "$target_sha"
|
rebuild_computer_image "$previous_sha" "$target_sha"
|
||||||
|
recreate_idle_computers "$previous_sha" "$target_sha"
|
||||||
log "deploy succeeded: $previous_sha -> $target_sha"
|
log "deploy succeeded: $previous_sha -> $target_sha"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,6 +94,8 @@ export interface SandboxProvider {
|
|||||||
request: {
|
request: {
|
||||||
botId: string;
|
botId: string;
|
||||||
homePath: string;
|
homePath: string;
|
||||||
|
/** Host folder bound at /home/rakazo/project for owner project spaces. */
|
||||||
|
projectWorkspacePath?: string;
|
||||||
providerRef?: string;
|
providerRef?: string;
|
||||||
providerKind?: ComputerRef["kind"];
|
providerKind?: ComputerRef["kind"];
|
||||||
},
|
},
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
restoreComputerWorkspace,
|
restoreComputerWorkspace,
|
||||||
} from "./computer-workspace.js";
|
} from "./computer-workspace.js";
|
||||||
import { resolveAgentHomePath } from "./home.js";
|
import { resolveAgentHomePath } from "./home.js";
|
||||||
|
import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js";
|
||||||
|
|
||||||
type ComputerUpdateProgress = (
|
type ComputerUpdateProgress = (
|
||||||
stage: Exclude<ComputerUpdate["stage"], "preparing">,
|
stage: Exclude<ComputerUpdate["stage"], "preparing">,
|
||||||
@ -280,10 +281,20 @@ export async function provisionComputer(
|
|||||||
let provisioned: ComputerRef | undefined;
|
let provisioned: ComputerRef | undefined;
|
||||||
try {
|
try {
|
||||||
await onProgress?.("recreating");
|
await onProgress?.("recreating");
|
||||||
|
let projectWorkspacePath: string | undefined;
|
||||||
|
try {
|
||||||
|
projectWorkspacePath = await resolveProjectWorkspaceBind(deps.prisma, {
|
||||||
|
spaceId: context.spaceId,
|
||||||
|
userId: context.userId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
projectWorkspacePath = undefined;
|
||||||
|
}
|
||||||
const ref = await deps.sandbox.provision(
|
const ref = await deps.sandbox.provision(
|
||||||
{
|
{
|
||||||
botId: existing.homeKey,
|
botId: existing.homeKey,
|
||||||
homePath,
|
homePath,
|
||||||
|
projectWorkspacePath,
|
||||||
providerRef: existing.providerRef ?? undefined,
|
providerRef: existing.providerRef ?? undefined,
|
||||||
providerKind: existing.kind as ComputerRef["kind"],
|
providerKind: existing.kind as ComputerRef["kind"],
|
||||||
},
|
},
|
||||||
|
|||||||
51
packages/adapters/src/continue-run-test-stubs.ts
Normal file
51
packages/adapters/src/continue-run-test-stubs.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
packages/adapters/src/deployment-github-token.test.ts
Normal file
47
packages/adapters/src/deployment-github-token.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/adapters/src/deployment-github-token.ts
Normal file
48
packages/adapters/src/deployment-github-token.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -137,7 +137,7 @@ export class DockerSandboxProvider implements SandboxProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async provision(
|
async provision(
|
||||||
request: { botId: string; homePath: string },
|
request: { botId: string; homePath: string; projectWorkspacePath?: string },
|
||||||
context: AdapterContext,
|
context: AdapterContext,
|
||||||
): Promise<ComputerRef> {
|
): Promise<ComputerRef> {
|
||||||
const res = await fetch(this.url("/computers"), {
|
const res = await fetch(this.url("/computers"), {
|
||||||
@ -147,6 +147,9 @@ export class DockerSandboxProvider implements SandboxProvider {
|
|||||||
botId: request.botId,
|
botId: request.botId,
|
||||||
homePath: request.homePath,
|
homePath: request.homePath,
|
||||||
spaceId: context.spaceId,
|
spaceId: context.spaceId,
|
||||||
|
...(request.projectWorkspacePath
|
||||||
|
? { projectWorkspacePath: request.projectWorkspacePath }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
signal: context.signal,
|
signal: context.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
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";
|
import { createRunExecutor, MAIN_ONLY_BUILTIN_TOOLS } from "./executor.js";
|
||||||
|
|
||||||
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
||||||
@ -126,6 +127,7 @@ async function runBot(
|
|||||||
const results: unknown[] = [];
|
const results: unknown[] = [];
|
||||||
const jobs = { enqueue: vi.fn(async () => undefined) };
|
const jobs = { enqueue: vi.fn(async () => undefined) };
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime: {
|
runtime: {
|
||||||
describe: () => ({ capabilities: { scripted: false } }),
|
describe: () => ({ capabilities: { scripted: false } }),
|
||||||
@ -148,6 +150,7 @@ async function runBot(
|
|||||||
jobs,
|
jobs,
|
||||||
secrets: [],
|
secrets: [],
|
||||||
...deps,
|
...deps,
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
await executor.continueRun(run.id, "worker-1");
|
await executor.continueRun(run.id, "worker-1");
|
||||||
if (!request) throw new Error("the runtime was never started");
|
if (!request) throw new Error("the runtime was never started");
|
||||||
|
|||||||
@ -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 { runContinueJob } from "@rakazo/adapter-kit";
|
||||||
import type { MessageBlock } from "@rakazo/contracts";
|
import type { MessageBlock } from "@rakazo/contracts";
|
||||||
import { redactSecrets } from "@rakazo/core";
|
import { redactSecrets } from "@rakazo/core";
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type * as AutoReviewModule from "./auto-review.js";
|
import type * as AutoReviewModule from "./auto-review.js";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
||||||
|
import { withContinueRunTestDeps } from "./continue-run-test-stubs.js";
|
||||||
import { createRunExecutor } from "./executor.js";
|
import { createRunExecutor } from "./executor.js";
|
||||||
|
|
||||||
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
||||||
@ -194,6 +195,7 @@ function fixture(runId = "run-1") {
|
|||||||
yield { type: "done" as const, text: "Done" };
|
yield { type: "done" as const, text: "Done" };
|
||||||
});
|
});
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
|
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
|
||||||
connector: {
|
connector: {
|
||||||
@ -213,6 +215,7 @@ function fixture(runId = "run-1") {
|
|||||||
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
|
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
|
||||||
jobs: { enqueue: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { MessageBlock } from "@rakazo/contracts";
|
|||||||
import { addDays, localDateIn } from "@rakazo/core";
|
import { addDays, localDateIn } from "@rakazo/core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
||||||
|
import { withContinueRunTestDeps } from "./continue-run-test-stubs.js";
|
||||||
import { createRunExecutor } from "./executor.js";
|
import { createRunExecutor } from "./executor.js";
|
||||||
|
|
||||||
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
||||||
@ -166,6 +167,7 @@ async function runOnce(options: FixtureOptions = {}) {
|
|||||||
abort: vi.fn(async () => undefined),
|
abort: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime,
|
runtime,
|
||||||
connector: {
|
connector: {
|
||||||
@ -189,6 +191,7 @@ async function runOnce(options: FixtureOptions = {}) {
|
|||||||
},
|
},
|
||||||
jobs: { enqueue: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
|
|
||||||
await executor.continueRun(run.id, "worker-1");
|
await executor.continueRun(run.id, "worker-1");
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import type { AdapterContext, AgentRunRequest } from "@rakazo/adapter-kit";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type * as AutoReviewModule from "./auto-review.js";
|
import type * as AutoReviewModule from "./auto-review.js";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
||||||
|
import {
|
||||||
|
minimalContinueRunHomeStub,
|
||||||
|
minimalContinueRunSecretStore,
|
||||||
|
stubContinueRunPrisma,
|
||||||
|
} from "./continue-run-test-stubs.js";
|
||||||
import { createRunExecutor } from "./executor.js";
|
import { createRunExecutor } from "./executor.js";
|
||||||
|
|
||||||
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
||||||
@ -126,9 +131,12 @@ function executorFixture() {
|
|||||||
findUnique: vi.fn(async () => null),
|
findUnique: vi.fn(async () => null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
stubContinueRunPrisma(prisma);
|
||||||
const { runtime, runtimeRun, runtimeAbort, blocked } = silentBlockingRuntime();
|
const { runtime, runtimeRun, runtimeAbort, blocked } = silentBlockingRuntime();
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
prisma,
|
prisma,
|
||||||
|
home: minimalContinueRunHomeStub(),
|
||||||
|
secretStore: minimalContinueRunSecretStore(),
|
||||||
runtime,
|
runtime,
|
||||||
connector: {
|
connector: {
|
||||||
discoverTools: async () => [],
|
discoverTools: async () => [],
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
formatBotIdentity,
|
formatBotIdentity,
|
||||||
formatConnectionsInstruction,
|
formatConnectionsInstruction,
|
||||||
|
formatGithubGitInstruction,
|
||||||
formatMainAgentRole,
|
formatMainAgentRole,
|
||||||
formatSubagentContext,
|
formatSubagentContext,
|
||||||
formatUserContext,
|
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", () => {
|
it("says plainly when nothing is connected", () => {
|
||||||
expect(formatConnectionsInstruction({ apps: [], tools: [] })).toBe(
|
expect(formatConnectionsInstruction({ apps: [], tools: [] })).toBe(
|
||||||
"No apps are connected yet. The user connects them under Settings, Integrations.",
|
"No apps are connected yet. The user connects them under Settings, Integrations.",
|
||||||
|
|||||||
@ -74,9 +74,16 @@ export const ROUTINE_RUN_INSTRUCTION =
|
|||||||
* neither denies a connected app nor claims one whose tools failed to load. Apps reached
|
* 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.
|
* 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: {
|
export function formatConnectionsInstruction(input: {
|
||||||
apps: readonly string[];
|
apps: readonly string[];
|
||||||
tools: readonly ConnectorTool[];
|
tools: readonly ConnectorTool[];
|
||||||
|
githubUnreachableHint?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const names = new Set(input.apps.map((app) => app.trim()).filter(Boolean));
|
const names = new Set(input.apps.map((app) => app.trim()).filter(Boolean));
|
||||||
const catalogs: string[] = [];
|
const catalogs: string[] = [];
|
||||||
@ -96,8 +103,11 @@ export function formatConnectionsInstruction(input: {
|
|||||||
}
|
}
|
||||||
return [
|
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.`,
|
`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,
|
...catalogs,
|
||||||
].join("\n");
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { isApprovalPausedResult } from "./approval-effect.js";
|
|||||||
import type * as AutoReviewModule from "./auto-review.js";
|
import type * as AutoReviewModule from "./auto-review.js";
|
||||||
import { runAutoReviewJudge } from "./auto-review.js";
|
import { runAutoReviewJudge } from "./auto-review.js";
|
||||||
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
||||||
|
import { withContinueRunTestDeps } from "./continue-run-test-stubs.js";
|
||||||
import { createRunExecutor } from "./executor.js";
|
import { createRunExecutor } from "./executor.js";
|
||||||
import { catalogEntries, resolveCatalogCall } from "./lazy-tool-catalog.js";
|
import { catalogEntries, resolveCatalogCall } from "./lazy-tool-catalog.js";
|
||||||
|
|
||||||
@ -174,6 +175,7 @@ function fixture({
|
|||||||
yield { type: "done" as const, text: "Done" };
|
yield { type: "done" as const, text: "Done" };
|
||||||
});
|
});
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
|
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
|
||||||
connector: {
|
connector: {
|
||||||
@ -198,6 +200,7 @@ function fixture({
|
|||||||
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
|
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
|
||||||
jobs: { enqueue: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
return {
|
return {
|
||||||
effects,
|
effects,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { ONCE_ROUTINE_CRON } from "@rakazo/core";
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { agentSecretScopeForRun } from "./agent-environment.js";
|
import { agentSecretScopeForRun } from "./agent-environment.js";
|
||||||
|
import { withContinueRunTestDeps } from "./continue-run-test-stubs.js";
|
||||||
import {
|
import {
|
||||||
appendToolCompletionAudit,
|
appendToolCompletionAudit,
|
||||||
createRunExecutor,
|
createRunExecutor,
|
||||||
@ -1259,6 +1260,7 @@ description: Prepare standup notes
|
|||||||
scratchpadItem: { findMany: vi.fn(async () => []) },
|
scratchpadItem: { findMany: vi.fn(async () => []) },
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime: {
|
runtime: {
|
||||||
describe: () => ({ capabilities: { scripted: false } }),
|
describe: () => ({ capabilities: { scripted: false } }),
|
||||||
@ -1269,6 +1271,7 @@ description: Prepare standup notes
|
|||||||
events: { append: vi.fn(async () => undefined), finalizeRun },
|
events: { append: vi.fn(async () => undefined), finalizeRun },
|
||||||
jobs: { enqueue: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
|
|
||||||
await executor.continueRun("run-1", "worker-1");
|
await executor.continueRun("run-1", "worker-1");
|
||||||
@ -1350,6 +1353,7 @@ description: Prepare standup notes
|
|||||||
scratchpadItem: { findMany: vi.fn(async () => []) },
|
scratchpadItem: { findMany: vi.fn(async () => []) },
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
const executor = createRunExecutor({
|
const executor = createRunExecutor({
|
||||||
|
...withContinueRunTestDeps({
|
||||||
prisma,
|
prisma,
|
||||||
runtime: {
|
runtime: {
|
||||||
describe: () => ({ capabilities: { scripted: false } }),
|
describe: () => ({ capabilities: { scripted: false } }),
|
||||||
@ -1363,6 +1367,7 @@ description: Prepare standup notes
|
|||||||
},
|
},
|
||||||
jobs: { enqueue: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
}),
|
||||||
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
||||||
|
|
||||||
await executor.continueRun("run-1", "worker-1");
|
await executor.continueRun("run-1", "worker-1");
|
||||||
|
|||||||
@ -1,23 +1,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type {
|
import type {
|
||||||
AdapterContext,
|
AdapterContext,
|
||||||
AgentHomeStore,
|
|
||||||
AgentModelOAuthCredential,
|
|
||||||
AgentRunRequest,
|
AgentRunRequest,
|
||||||
AgentRuntime,
|
AgentRuntime,
|
||||||
AgentToolCompletion,
|
AgentToolCompletion,
|
||||||
ArtifactStore,
|
|
||||||
BrowserProvider,
|
|
||||||
ComputerRef,
|
ComputerRef,
|
||||||
ConnectorCall,
|
ConnectorCall,
|
||||||
ConnectorProvider,
|
|
||||||
JobPublisher,
|
JobPublisher,
|
||||||
ManagedConnectorProvider,
|
|
||||||
MemoryStore,
|
|
||||||
NotificationProvider,
|
|
||||||
SandboxProvider,
|
SandboxProvider,
|
||||||
SemanticMemoryProvider,
|
SemanticMemoryProvider,
|
||||||
WebProvider,
|
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import {
|
import {
|
||||||
ArtifactTooLargeError,
|
ArtifactTooLargeError,
|
||||||
@ -34,12 +25,10 @@ import {
|
|||||||
BOT_TITLE_MAX_LENGTH,
|
BOT_TITLE_MAX_LENGTH,
|
||||||
BotSecretName,
|
BotSecretName,
|
||||||
BotSecretSubmission,
|
BotSecretSubmission,
|
||||||
isAttachmentImageMimeType,
|
|
||||||
MediaLibraryImportInputSchema,
|
MediaLibraryImportInputSchema,
|
||||||
MediaLibrarySearchInputSchema,
|
MediaLibrarySearchInputSchema,
|
||||||
type MessageBlock,
|
type MessageBlock,
|
||||||
MessageBlock as MessageBlockSchema,
|
MessageBlock as MessageBlockSchema,
|
||||||
OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import type { ShowCardArgs } from "@rakazo/core";
|
import type { ShowCardArgs } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
@ -87,18 +76,10 @@ import {
|
|||||||
unattendedTriggerToolRequiresApproval,
|
unattendedTriggerToolRequiresApproval,
|
||||||
userTurnBlocksForRun,
|
userTurnBlocksForRun,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import {
|
import { approvalEffectKey, toolEffectIdempotencyKey } from "@rakazo/core/node/approval-effect-key";
|
||||||
approvalEffectKey,
|
|
||||||
isToolEffectIdempotencyKey,
|
|
||||||
legacyScopedToolEffectIdempotencyKey,
|
|
||||||
stableJsonValue,
|
|
||||||
toolEffectIdempotencyKey,
|
|
||||||
} from "@rakazo/core/node/approval-effect-key";
|
|
||||||
import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
import {
|
import {
|
||||||
appendEventInTransaction,
|
|
||||||
createSpaceForMember,
|
createSpaceForMember,
|
||||||
createThreadMessageInTransaction,
|
|
||||||
effectiveMemoryScope,
|
effectiveMemoryScope,
|
||||||
ensureMainConversation,
|
ensureMainConversation,
|
||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
@ -187,10 +168,9 @@ import {
|
|||||||
} from "./browser-tools.js";
|
} from "./browser-tools.js";
|
||||||
import { builtinAgentTools } from "./builtin-tools.js";
|
import { builtinAgentTools } from "./builtin-tools.js";
|
||||||
import { archiveSpawnedBot, spawnBot } from "./child-bots.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 { executeCloudAgentTool } from "./cloud-agent-service.js";
|
||||||
import { validCloudAgentArgs } from "./cloud-agent-tools.js";
|
import { validCloudAgentArgs } from "./cloud-agent-tools.js";
|
||||||
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
|
|
||||||
import { executeCoachTool, isCoachToolName } from "./coach-tools.js";
|
import { executeCoachTool, isCoachToolName } from "./coach-tools.js";
|
||||||
import {
|
import {
|
||||||
collectLogIds,
|
collectLogIds,
|
||||||
@ -215,7 +195,6 @@ import {
|
|||||||
computerPointerPayload,
|
computerPointerPayload,
|
||||||
computerPointerSteps,
|
computerPointerSteps,
|
||||||
} from "./computer-pointer.js";
|
} from "./computer-pointer.js";
|
||||||
import { withComputerScreenAvailability } from "./computer-screens.js";
|
|
||||||
import {
|
import {
|
||||||
assertTeamShellCommandAllowed,
|
assertTeamShellCommandAllowed,
|
||||||
assertTeamWorkspacePathAllowed,
|
assertTeamWorkspacePathAllowed,
|
||||||
@ -237,12 +216,14 @@ import {
|
|||||||
loadDelegatedTaskInstruction,
|
loadDelegatedTaskInstruction,
|
||||||
syncDelegatedTaskRun,
|
syncDelegatedTaskRun,
|
||||||
} from "./delegated-tasks.js";
|
} from "./delegated-tasks.js";
|
||||||
|
import {
|
||||||
|
githubIntegrationEnvironmentForRun,
|
||||||
|
githubIntegrationTokenForBroker,
|
||||||
|
} from "./deployment-github-token.js";
|
||||||
import { resolveDeploymentModel } from "./deployment-model.js";
|
import { resolveDeploymentModel } from "./deployment-model.js";
|
||||||
import {
|
import {
|
||||||
missingTurnImagesInstruction,
|
missingTurnImagesInstruction,
|
||||||
STEERING_ATTACHMENT_UNAVAILABLE,
|
|
||||||
settleSteeringAttachmentLoads,
|
settleSteeringAttachmentLoads,
|
||||||
TURN_ATTACHMENT_UNAVAILABLE,
|
|
||||||
} from "./executor-attachments.js";
|
} from "./executor-attachments.js";
|
||||||
import { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js";
|
import { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js";
|
||||||
import {
|
import {
|
||||||
@ -254,7 +235,6 @@ import {
|
|||||||
} from "./executor-completion.js";
|
} from "./executor-completion.js";
|
||||||
import {
|
import {
|
||||||
computerRetryDelay,
|
computerRetryDelay,
|
||||||
computerRunRequeueData,
|
|
||||||
computerScreenToolResult,
|
computerScreenToolResult,
|
||||||
notifyRun,
|
notifyRun,
|
||||||
persistMessageInTransaction,
|
persistMessageInTransaction,
|
||||||
@ -262,14 +242,14 @@ import {
|
|||||||
redactBlocks,
|
redactBlocks,
|
||||||
renewRunLease,
|
renewRunLease,
|
||||||
requeueComputerRun,
|
requeueComputerRun,
|
||||||
runNotificationsEnabled,
|
|
||||||
writeComputerRunRequeue,
|
writeComputerRunRequeue,
|
||||||
} from "./executor-computer.js";
|
} from "./executor-computer.js";
|
||||||
import { completeEffect, recordEffect, uncertainEffectError } from "./executor-effects.js";
|
import { completeEffect, recordEffect, uncertainEffectError } from "./executor-effects.js";
|
||||||
import { deploymentKeyFor, resolveModelKey, withModelCredentialLock } from "./executor-model.js";
|
import { resolveModelKey } from "./executor-model.js";
|
||||||
import {
|
import {
|
||||||
formatBotIdentity,
|
formatBotIdentity,
|
||||||
formatConnectionsInstruction,
|
formatConnectionsInstruction,
|
||||||
|
formatGithubGitInstruction,
|
||||||
formatMainAgentRole,
|
formatMainAgentRole,
|
||||||
formatSubagentContext,
|
formatSubagentContext,
|
||||||
formatUserContext,
|
formatUserContext,
|
||||||
@ -282,13 +262,15 @@ import {
|
|||||||
PROTECTED_SCRIPT_SCAN_BYTES,
|
PROTECTED_SCRIPT_SCAN_BYTES,
|
||||||
protectedScriptWorkspacePath,
|
protectedScriptWorkspacePath,
|
||||||
} from "./executor-shell-guard.js";
|
} from "./executor-shell-guard.js";
|
||||||
import {
|
import { PAGE_BROWSER_TOOL_NAMES, selectBuiltinToolsForRun } from "./executor-tools.js";
|
||||||
filterPageBrowserTools,
|
|
||||||
PAGE_BROWSER_TOOL_NAMES,
|
|
||||||
selectBuiltinToolsForRun,
|
|
||||||
} from "./executor-tools.js";
|
|
||||||
import type { ExecutorDeps } from "./executor-types.js";
|
import type { ExecutorDeps } from "./executor-types.js";
|
||||||
import { loadCurrentTurnImages, selectRunConnections } from "./executor-utils.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 { handoffToGroupBot, loadGroupContext } from "./group-handoff.js";
|
||||||
import {
|
import {
|
||||||
COMPACTION_BATCH_SIZE,
|
COMPACTION_BATCH_SIZE,
|
||||||
@ -301,6 +283,8 @@ import {
|
|||||||
selectCompactedHistory,
|
selectCompactedHistory,
|
||||||
shouldEnqueueCompaction,
|
shouldEnqueueCompaction,
|
||||||
} from "./history-compaction.js";
|
} from "./history-compaction.js";
|
||||||
|
import { resolveAgentHomePath } from "./home.js";
|
||||||
|
import { githubIntegrationDiscoveryHint } from "./installed-connectors.js";
|
||||||
import {
|
import {
|
||||||
assertConnectorToolArgs,
|
assertConnectorToolArgs,
|
||||||
CATALOG_EXECUTE,
|
CATALOG_EXECUTE,
|
||||||
@ -319,29 +303,18 @@ import {
|
|||||||
} from "./media-library.js";
|
} from "./media-library.js";
|
||||||
import { executeMediaTool, isMediaToolName } from "./media-tools.js";
|
import { executeMediaTool, isMediaToolName } from "./media-tools.js";
|
||||||
import { loadAgentMemoryContext } from "./memory-context.js";
|
import { loadAgentMemoryContext } from "./memory-context.js";
|
||||||
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
|
||||||
import { selectMemoryTools } from "./memory-tools.js";
|
|
||||||
import {
|
import {
|
||||||
isCatalogModelChoice,
|
isCatalogModelChoice,
|
||||||
selectConfiguredModel,
|
selectConfiguredModel,
|
||||||
validateConnectedModelChoice,
|
validateConnectedModelChoice,
|
||||||
} from "./model-selection.js";
|
} from "./model-selection.js";
|
||||||
import {
|
import {
|
||||||
filterImageReturningComputerTools,
|
|
||||||
IMAGE_RETURNING_COMPUTER_TOOLS,
|
IMAGE_RETURNING_COMPUTER_TOOLS,
|
||||||
MODEL_CANNOT_SEE_MESSAGE,
|
MODEL_CANNOT_SEE_MESSAGE,
|
||||||
modelAcceptsImageInput,
|
modelAcceptsImageInput,
|
||||||
modelIdSupportsImages,
|
|
||||||
} from "./model-vision.js";
|
} from "./model-vision.js";
|
||||||
import { notifyUserFromTool } from "./notify-user-tool.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 { LOCAL_PROVIDER_ID, localTierModels } from "./pi-local-provider.js";
|
||||||
import {
|
|
||||||
parseModelSecret,
|
|
||||||
resolveModelAuth,
|
|
||||||
secretValuesToRedact,
|
|
||||||
serializeModelSecret,
|
|
||||||
} from "./pi-oauth.js";
|
|
||||||
import {
|
import {
|
||||||
assertPlotDataWithinLimits,
|
assertPlotDataWithinLimits,
|
||||||
PLOT_TOOL_GUIDE,
|
PLOT_TOOL_GUIDE,
|
||||||
@ -352,12 +325,12 @@ import {
|
|||||||
searchChartCatalog,
|
searchChartCatalog,
|
||||||
} from "./plot-tool.js";
|
} from "./plot-tool.js";
|
||||||
import { formatProductSelfModificationInstruction } from "./product-self-modification.js";
|
import { formatProductSelfModificationInstruction } from "./product-self-modification.js";
|
||||||
|
import { resolveProjectWorkspaceBind } from "./project-workspace-bind.js";
|
||||||
import {
|
import {
|
||||||
tryRecoverProseHandoff,
|
tryRecoverProseHandoff,
|
||||||
workshopFallbackAfterBlockedHandoff,
|
workshopFallbackAfterBlockedHandoff,
|
||||||
} from "./prose-handoff-recovery.js";
|
} from "./prose-handoff-recovery.js";
|
||||||
import { datedMemoryNote, rememberFromTool } from "./remember.js";
|
import { datedMemoryNote, rememberFromTool } from "./remember.js";
|
||||||
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
|
||||||
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
|
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
|
||||||
import {
|
import {
|
||||||
commitConsumedRunSecret,
|
commitConsumedRunSecret,
|
||||||
@ -373,8 +346,6 @@ import { withRuntimeCleanup } from "./runtime-stream.js";
|
|||||||
import {
|
import {
|
||||||
cancelScheduleFromTool,
|
cancelScheduleFromTool,
|
||||||
createScheduleFromTool,
|
createScheduleFromTool,
|
||||||
filterBuiltinToolsForRun,
|
|
||||||
filterBuiltinToolsForThread,
|
|
||||||
listSchedulesFromTool,
|
listSchedulesFromTool,
|
||||||
resolveScheduleOwner,
|
resolveScheduleOwner,
|
||||||
} from "./schedule-tools.js";
|
} from "./schedule-tools.js";
|
||||||
@ -387,7 +358,6 @@ import {
|
|||||||
updateScratchpadItemFromTool,
|
updateScratchpadItemFromTool,
|
||||||
} from "./scratchpad-tools.js";
|
} from "./scratchpad-tools.js";
|
||||||
import { inferScript, ScriptedAgentRuntime } from "./scripted-runtime.js";
|
import { inferScript, ScriptedAgentRuntime } from "./scripted-runtime.js";
|
||||||
import type { EncryptedSecretStore } from "./secrets.js";
|
|
||||||
import {
|
import {
|
||||||
listAgentSkillRecords,
|
listAgentSkillRecords,
|
||||||
skillCreateFromTool,
|
skillCreateFromTool,
|
||||||
@ -407,8 +377,6 @@ import {
|
|||||||
continueRunClaimFence,
|
continueRunClaimFence,
|
||||||
DESKTOP_HELD_FOR_TAKEOVER_MESSAGE,
|
DESKTOP_HELD_FOR_TAKEOVER_MESSAGE,
|
||||||
refreshTakeoverContinuePlan,
|
refreshTakeoverContinuePlan,
|
||||||
TAKEOVER_RESUME_CHECKPOINTS,
|
|
||||||
type TakeoverResumeCheckpoint,
|
|
||||||
takeoverCheckpointOf,
|
takeoverCheckpointOf,
|
||||||
takeoverContinuePlan,
|
takeoverContinuePlan,
|
||||||
} from "./takeover-resume.js";
|
} from "./takeover-resume.js";
|
||||||
@ -1173,6 +1141,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
agentSkills,
|
agentSkills,
|
||||||
agentSecretRows,
|
agentSecretRows,
|
||||||
userContext,
|
userContext,
|
||||||
|
spaceRecord,
|
||||||
|
projectWorkspacePath,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
deps.prisma.bot.findUniqueOrThrow({
|
deps.prisma.bot.findUniqueOrThrow({
|
||||||
where: { id: run.botId },
|
where: { id: run.botId },
|
||||||
@ -1213,8 +1183,42 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
loadUserContext(deps.prisma, { userId: run.userId, spaceId: run.spaceId }),
|
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));
|
runSecrets.push(...Object.values(agentEnvironment));
|
||||||
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment);
|
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentEnvironment);
|
||||||
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
|
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
|
||||||
@ -1631,6 +1635,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
select: { kind: true, request: true },
|
select: { kind: true, request: true },
|
||||||
});
|
});
|
||||||
const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects);
|
const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects);
|
||||||
|
const gitPushApprovedRuns = new Set<string>();
|
||||||
const computerInstruction = heldForTakeover
|
const computerInstruction = heldForTakeover
|
||||||
? DESKTOP_HELD_FOR_TAKEOVER_MESSAGE
|
? DESKTOP_HELD_FOR_TAKEOVER_MESSAGE
|
||||||
: graphicalToolsAllowed
|
: graphicalToolsAllowed
|
||||||
@ -1643,10 +1648,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
cloudAgentEnabled,
|
cloudAgentEnabled,
|
||||||
teamComputer: computerMode === "team",
|
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 =
|
const workspaceInstruction =
|
||||||
computerMode === "team"
|
(computerMode === "team"
|
||||||
? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/.`
|
? `Your Team Computer home is ${teamBotWorkspaceDirectory(bot.id)}. Relative file paths and shell working directories start there. Put intentionally shared work under shared/. Other bots' folders are visible under bots/; treat them as their working areas. When this space links a git repository, its source files live under project/.`
|
||||||
: "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 assembled = "";
|
||||||
let currentTextSegment = "";
|
let currentTextSegment = "";
|
||||||
@ -2006,10 +2015,27 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
name,
|
name,
|
||||||
viaConnector,
|
viaConnector,
|
||||||
);
|
);
|
||||||
|
const shellCommand = name === "shell" ? String(args.command ?? args.cmd ?? "") : "";
|
||||||
|
const gitShellPolicy =
|
||||||
|
name === "shell"
|
||||||
|
? evaluateGitShellPolicy({
|
||||||
|
command: shellCommand,
|
||||||
|
defaultBranch: spaceRecord?.gitDefaultBranch,
|
||||||
|
gitPushApprovedThisRun: gitPushApprovedRuns.has(runId),
|
||||||
|
})
|
||||||
|
: { action: "allow" as const };
|
||||||
|
if (gitShellPolicy.action === "block") {
|
||||||
|
return { error: gitShellPolicy.reason };
|
||||||
|
}
|
||||||
|
const gitShellNeedsApproval = gitShellPolicy.action === "require_approval";
|
||||||
const requiresApprovalByDefault =
|
const requiresApprovalByDefault =
|
||||||
requiresUnattendedApproval || toolRequiresApproval(name, viaConnector);
|
requiresUnattendedApproval ||
|
||||||
|
toolRequiresApproval(name, viaConnector) ||
|
||||||
|
gitShellNeedsApproval;
|
||||||
const requiresMandatoryApproval =
|
const requiresMandatoryApproval =
|
||||||
requiresUnattendedApproval || toolRequiresExplicitApproval(name);
|
requiresUnattendedApproval ||
|
||||||
|
toolRequiresExplicitApproval(name) ||
|
||||||
|
gitShellNeedsApproval;
|
||||||
const connectorKind = connectorKindFromToolName(
|
const connectorKind = connectorKindFromToolName(
|
||||||
name,
|
name,
|
||||||
connectedPlugins.map((plugin) => plugin.provider),
|
connectedPlugins.map((plugin) => plugin.provider),
|
||||||
@ -2044,8 +2070,17 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
autoReviewEnabled: autoReviewPref,
|
autoReviewEnabled: autoReviewPref,
|
||||||
checkerConfigured,
|
checkerConfigured,
|
||||||
});
|
});
|
||||||
let reviewReason: string | undefined;
|
let reviewReason: string | undefined =
|
||||||
|
gitShellNeedsApproval &&
|
||||||
|
typeof gitShellPolicy === "object" &&
|
||||||
|
"reason" in gitShellPolicy
|
||||||
|
? gitShellPolicy.reason
|
||||||
|
: undefined;
|
||||||
let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow";
|
let gateDecision: "ask" | "allow" = plan === "ask" ? "ask" : "allow";
|
||||||
|
if (gitShellNeedsApproval && nextApprovedTool) {
|
||||||
|
gitPushApprovedRuns.add(runId);
|
||||||
|
void setGithubBrokerPushApproved(runId, true);
|
||||||
|
}
|
||||||
const needsApprovalEarly = plan === "ask" || plan === "judge";
|
const needsApprovalEarly = plan === "ask" || plan === "judge";
|
||||||
// A resumed approval keeps its key even if "Always allow" changed the policy.
|
// A resumed approval keeps its key even if "Always allow" changed the policy.
|
||||||
const usesApprovalKey =
|
const usesApprovalKey =
|
||||||
@ -3911,10 +3946,19 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
return finish({ error: `unknown tool ${name}` });
|
return finish({ error: `unknown tool ${name}` });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const githubUnreachableHint = await githubIntegrationDiscoveryHint(
|
||||||
|
deps.prisma,
|
||||||
|
run.userId,
|
||||||
|
exposedConnectorTools,
|
||||||
|
);
|
||||||
const connectionsLine = formatConnectionsInstruction({
|
const connectionsLine = formatConnectionsInstruction({
|
||||||
apps: connectedPlugins.map((row) => row.displayName),
|
apps: connectedPlugins.map((row) => row.displayName),
|
||||||
tools: exposedConnectorTools,
|
tools: exposedConnectorTools,
|
||||||
|
githubUnreachableHint,
|
||||||
});
|
});
|
||||||
|
const githubGitInstruction = formatGithubGitInstruction(
|
||||||
|
connectedPlugins.map((row) => row.displayName),
|
||||||
|
);
|
||||||
const taughtSkillIndex = savedSkills.slice(0, 20);
|
const taughtSkillIndex = savedSkills.slice(0, 20);
|
||||||
const taughtSkillsLine =
|
const taughtSkillsLine =
|
||||||
taughtSkillIndex.length > 0
|
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."
|
? "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,
|
: undefined,
|
||||||
connectionsLine,
|
connectionsLine,
|
||||||
|
githubGitInstruction,
|
||||||
agentSkillsLine,
|
agentSkillsLine,
|
||||||
taughtSkillsLine,
|
taughtSkillsLine,
|
||||||
exposedToolNames.has("render_plot")
|
exposedToolNames.has("render_plot")
|
||||||
@ -4903,6 +4948,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
throw new Error("Run setup failed; retrying");
|
throw new Error("Run setup failed; retrying");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
void deleteGithubBrokerSession(runId);
|
||||||
detachShutdown?.();
|
detachShutdown?.();
|
||||||
clearInterval(leasePoll);
|
clearInterval(leasePoll);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
|
|||||||
48
packages/adapters/src/git-shell-policy.test.ts
Normal file
48
packages/adapters/src/git-shell-policy.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { evaluateGitShellPolicy, parseGitPushRef } from "./git-shell-policy.js";
|
||||||
|
|
||||||
|
describe("git shell policy", () => {
|
||||||
|
it("blocks force push", () => {
|
||||||
|
expect(
|
||||||
|
evaluateGitShellPolicy({
|
||||||
|
command: "git push --force origin feature",
|
||||||
|
defaultBranch: "main",
|
||||||
|
gitPushApprovedThisRun: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ action: "block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks push to the default branch", () => {
|
||||||
|
expect(
|
||||||
|
evaluateGitShellPolicy({
|
||||||
|
command: "git push origin main",
|
||||||
|
defaultBranch: "main",
|
||||||
|
gitPushApprovedThisRun: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ action: "block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires approval before the first push of a run", () => {
|
||||||
|
expect(
|
||||||
|
evaluateGitShellPolicy({
|
||||||
|
command: "git push -u origin feature/login",
|
||||||
|
defaultBranch: "main",
|
||||||
|
gitPushApprovedThisRun: false,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ action: "require_approval" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows feature branch push after approval", () => {
|
||||||
|
expect(
|
||||||
|
evaluateGitShellPolicy({
|
||||||
|
command: "git push -u origin feature/login",
|
||||||
|
defaultBranch: "main",
|
||||||
|
gitPushApprovedThisRun: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ action: "allow" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses refspec destinations", () => {
|
||||||
|
expect(parseGitPushRef("git push origin HEAD:feature/x")).toBe("feature/x");
|
||||||
|
});
|
||||||
|
});
|
||||||
8
packages/adapters/src/git-shell-policy.ts
Normal file
8
packages/adapters/src/git-shell-policy.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
evaluateGitShellPolicy,
|
||||||
|
type GitShellPolicyDecision,
|
||||||
|
type GitShellPolicyInput,
|
||||||
|
ghBrokerArgv,
|
||||||
|
gitNetworkArgv,
|
||||||
|
parseGitPushRef,
|
||||||
|
} from "@rakazo/core";
|
||||||
86
packages/adapters/src/github-broker-client.ts
Normal file
86
packages/adapters/src/github-broker-client.ts
Normal 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);
|
||||||
|
}
|
||||||
36
packages/adapters/src/github-broker-integration.test.ts
Normal file
36
packages/adapters/src/github-broker-integration.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,7 +5,9 @@ import type {
|
|||||||
ConnectorProvider,
|
ConnectorProvider,
|
||||||
ConnectorTool,
|
ConnectorTool,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
|
import { isGithubInstall } from "@rakazo/core";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
AuthSchema,
|
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.
|
// One unavailable user-installed source must not hide the other tools.
|
||||||
}
|
}
|
||||||
return [];
|
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: {
|
export async function verifyMcpInstall(input: {
|
||||||
source: string;
|
source: string;
|
||||||
config: unknown;
|
config: unknown;
|
||||||
|
|||||||
26
packages/adapters/src/project-workspace-bind.ts
Normal file
26
packages/adapters/src/project-workspace-bind.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { resolveProjectWorkspaceDirectory } from "@rakazo/db";
|
||||||
|
|
||||||
|
/** Resolved host folder to bind at /home/rakazo/project for the deployment owner's project spaces. */
|
||||||
|
export async function resolveProjectWorkspaceBind(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: { spaceId: string; userId: string },
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const [space, settings] = await Promise.all([
|
||||||
|
prisma.space.findUnique({
|
||||||
|
where: { id: input.spaceId },
|
||||||
|
select: { workspacePath: true, isDefault: true },
|
||||||
|
}),
|
||||||
|
prisma.deploymentSettings.findUnique({
|
||||||
|
where: { id: "default" },
|
||||||
|
select: { ownerUserId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!space?.workspacePath || space.isDefault) return undefined;
|
||||||
|
if (settings?.ownerUserId !== input.userId) return undefined;
|
||||||
|
try {
|
||||||
|
return await resolveProjectWorkspaceDirectory(space.workspacePath);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,11 @@
|
|||||||
"development": "./src/media/workflow-template.ts",
|
"development": "./src/media/workflow-template.ts",
|
||||||
"default": "./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": {
|
"./node/desktop-runtime": {
|
||||||
"types": "./src/node/desktop-runtime.ts",
|
"types": "./src/node/desktop-runtime.ts",
|
||||||
"development": "./src/node/desktop-runtime.ts",
|
"development": "./src/node/desktop-runtime.ts",
|
||||||
|
|||||||
95
packages/core/src/git-shell-policy.ts
Normal file
95
packages/core/src/git-shell-policy.ts
Normal 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);
|
||||||
|
}
|
||||||
18
packages/core/src/github-broker-token.ts
Normal file
18
packages/core/src/github-broker-token.ts
Normal 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);
|
||||||
|
}
|
||||||
30
packages/core/src/github-remote-allowlist.ts
Normal file
30
packages/core/src/github-remote-allowlist.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -21,7 +21,9 @@ export * from "./cron.js";
|
|||||||
export * from "./delegated-tasks.js";
|
export * from "./delegated-tasks.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
export * from "./featured-connectors.js";
|
export * from "./featured-connectors.js";
|
||||||
|
export * from "./git-shell-policy.js";
|
||||||
export * from "./github-connector.js";
|
export * from "./github-connector.js";
|
||||||
|
export * from "./github-remote-allowlist.js";
|
||||||
export * from "./group-mentions.js";
|
export * from "./group-mentions.js";
|
||||||
export * from "./http-response.js";
|
export * from "./http-response.js";
|
||||||
export * from "./mcp.js";
|
export * from "./mcp.js";
|
||||||
|
|||||||
11
packages/db/src/project-workspace-mount.test.ts
Normal file
11
packages/db/src/project-workspace-mount.test.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertProjectWorkspaceMountAllowed } from "./project-workspace-mount.js";
|
||||||
|
|
||||||
|
describe("assertProjectWorkspaceMountAllowed", () => {
|
||||||
|
it("rejects the filesystem root and the user home directory", () => {
|
||||||
|
expect(() => assertProjectWorkspaceMountAllowed(path.parse("/").root)).toThrow(/root/);
|
||||||
|
expect(() => assertProjectWorkspaceMountAllowed(homedir())).toThrow(/home directory/);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
packages/db/src/project-workspace-mount.ts
Normal file
15
packages/db/src/project-workspace-mount.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { InvalidProjectWorkspacePathError } from "./project-workspace.js";
|
||||||
|
|
||||||
|
/** Host folder allowed to bind into a project computer at /home/rakazo/project. */
|
||||||
|
export function assertProjectWorkspaceMountAllowed(resolvedDirectory: string): void {
|
||||||
|
const normalized = path.resolve(resolvedDirectory);
|
||||||
|
if (normalized === path.parse(normalized).root) {
|
||||||
|
throw new InvalidProjectWorkspacePathError("The project folder cannot be the filesystem root.");
|
||||||
|
}
|
||||||
|
const home = path.resolve(homedir());
|
||||||
|
if (normalized === home) {
|
||||||
|
throw new InvalidProjectWorkspacePathError("The project folder cannot be your home directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/testkit/src/computer-image-tools.test.ts
Normal file
35
packages/testkit/src/computer-image-tools.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -191,7 +191,9 @@ describe("evo deploy", () => {
|
|||||||
write(path.join(changed.checkout, "../bin/docker"), "#!/bin/bash\n", 0o755);
|
write(path.join(changed.checkout, "../bin/docker"), "#!/bin/bash\n", 0o755);
|
||||||
const result = changed.run();
|
const result = changed.run();
|
||||||
expect(result.status, result.stderr).toBe(0);
|
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", () => {
|
it("stops a failing build before the restart and keeps the served build", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user