mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(pr): prevent parallel main refreshes from colliding (#134014)
* fix: restore gateway recovery fixture lint and types Preserve the recovery coverage added in #134165 while using the actual WebSocket dependency and authored SecretRef fixture boundary. Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com> * fix(pr): prevent parallel main refreshes from colliding * test(pr): observe private main refresh checkpoints * fix: authenticate CI checkout and lazy blob fetches * fix: prepare immutable reader history during CI checkout * fix(ci): declare historical reader selector entry point * test: include historical prerequisite in routing expectations * perf(anthropic): defer auth and catalog runtime imports Keep provider descriptors synchronous without importing credential stores, external auth discovery, or live catalog transport during registration. Load those implementations only when the existing asynchronous setup, doctor, usage, and catalog hooks need them. Expose the existing lightweight API-key descriptor factory through provider-entry while preserving the shipped auth SDK helpers and their synchronous contracts. The unchanged fresh-process registry proof retains its original timeout and verifies scoped failover without global activation. * test(ui): await agent menu lifecycle before keyboard navigation Wait for opening focus initialization and visible popup retirement before exercising selection and typing ownership. Preserve the existing focus, selected-agent, and composer assertions without changing product behavior or timeouts. * fix(ci): prepare Testbox against the checked-out merge base * docs(plugins): use public provider auth import in tutorial --------- Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
This commit is contained in:
parent
00d542d3dc
commit
9cc05cca83
29 changed files with 1369 additions and 380 deletions
79
.github/actions/git-owner/owner.py
vendored
79
.github/actions/git-owner/owner.py
vendored
|
|
@ -1,3 +1,5 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
|
|
@ -14,6 +16,7 @@ cleanup_seconds = 10
|
|||
cancelled = 0
|
||||
closed = False
|
||||
git = shutil.which("git")
|
||||
checkout_environment = {}
|
||||
|
||||
|
||||
def cancel(signum, _frame):
|
||||
|
|
@ -199,6 +202,23 @@ def git_lock_files(directory):
|
|||
return locks
|
||||
|
||||
|
||||
def git_auth_environment(remote, token):
|
||||
# Git's promisor fetch inherits this process-only config from checkout.
|
||||
# Reject redirects: http.<url> matching does not re-scope redirected requests.
|
||||
count = int(os.environ.get("GIT_CONFIG_COUNT", "0"))
|
||||
if count < 0:
|
||||
raise ValueError("Invalid Git environment configuration count")
|
||||
header = f"http.{remote}.extraheader"
|
||||
authorization = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
settings = [(header, ""), (header, f"AUTHORIZATION: basic {authorization}"),
|
||||
(f"http.{remote}.followRedirects", "false")]
|
||||
environment = {"GIT_CONFIG_COUNT": str(count + len(settings)), "GIT_TERMINAL_PROMPT": "0"}
|
||||
for index, (key, value) in enumerate(settings, count):
|
||||
environment[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
environment[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return environment
|
||||
|
||||
|
||||
def run_git(directory, *arguments, timeout=None, stdout=None, stderr=None, env=None,
|
||||
reclaim_locks=False):
|
||||
global closed
|
||||
|
|
@ -216,8 +236,10 @@ def run_git(directory, *arguments, timeout=None, stdout=None, stderr=None, env=N
|
|||
timed_out = False
|
||||
deadline = time.monotonic() + timeout if timeout is not None else None
|
||||
try:
|
||||
environment = ({**os.environ, **checkout_environment, **(env or {})}
|
||||
if checkout_environment or env is not None else None)
|
||||
options = {"stdin": subprocess.DEVNULL, "stdout": stdout, "stderr": stderr,
|
||||
"env": {**os.environ, **env} if env is not None else None}
|
||||
"env": environment}
|
||||
if os.name == "nt":
|
||||
job = create_job(None, None)
|
||||
limits = ExtendedLimits()
|
||||
|
|
@ -341,6 +363,14 @@ def checkout_selected_ref():
|
|||
|
||||
def checkout():
|
||||
check_cancelled()
|
||||
prerequisites = json.loads(os.environ.get("CHECKOUT_GIT_COMMITS_JSON", "null")) if kind == "linux-node" else None
|
||||
if prerequisites is None:
|
||||
prerequisites = []
|
||||
if not isinstance(prerequisites, list) or any(
|
||||
not isinstance(commit, str) or not re.fullmatch("[0-9a-f]{40}", commit)
|
||||
for commit in prerequisites
|
||||
):
|
||||
raise ValueError("Invalid immutable test prerequisite commits")
|
||||
if reset:
|
||||
os.makedirs(workspace, exist_ok=True)
|
||||
# Every earlier Git group has been drained before deleting its workspace.
|
||||
|
|
@ -360,6 +390,9 @@ def checkout():
|
|||
base = os.environ.get("CHECKOUT_BASE_SHA") if kind == "linux-node" else None
|
||||
if base:
|
||||
refs.append(f"+{base}:refs/remotes/origin/ci-ratchet-base")
|
||||
# Fetch full reader objects with the authenticated checkout, before its
|
||||
# credential scope ends and test workers create historical worktrees.
|
||||
refs.extend(prerequisites)
|
||||
fetch(workspace, *refs, prune=True, max_attempts=1 if reset else 3,
|
||||
retry_codes=(124, 137) if kind == "skills" else ())
|
||||
run_git(workspace, "checkout", *(["--force"] if reset else []), "--detach",
|
||||
|
|
@ -422,29 +455,37 @@ def main():
|
|||
raise SystemExit(0)
|
||||
workspace = os.environ["GITHUB_WORKSPACE"]
|
||||
remote = f"https://github.com/{os.environ['CHECKOUT_REPO']}.git"
|
||||
# The workflow's token is repository-bound; never lend it to a sibling checkout.
|
||||
token = os.environ.pop("CHECKOUT_TOKEN", "")
|
||||
if token and os.environ["CHECKOUT_REPO"] == os.environ.get("GITHUB_REPOSITORY"):
|
||||
checkout_environment.update(git_auth_environment(remote, token))
|
||||
del token
|
||||
if kind == "clawhub":
|
||||
workspace = os.path.join(workspace, "clawhub-source")
|
||||
reset = kind in ("linux-node", "android", "clawhub")
|
||||
label = "ClawHub checkout" if kind == "clawhub" else "checkout"
|
||||
started_at = time.monotonic()
|
||||
for attempt in range(1, 6 if reset else 2):
|
||||
try:
|
||||
checkout()
|
||||
if reset:
|
||||
print(f"{label} attempt {attempt}/5 succeeded", flush=True)
|
||||
if kind == "clawhub":
|
||||
print(f"{label} completed in {int(time.monotonic() - started_at)}s", flush=True)
|
||||
raise SystemExit(0)
|
||||
except (FetchTimeout, GitFailure) as error:
|
||||
# Only command failures are retryable. Ownership/inspection errors
|
||||
# escape to the fail-closed boundary below, never workspace deletion.
|
||||
check_cancelled()
|
||||
if not reset:
|
||||
raise SystemExit(124 if isinstance(error, FetchTimeout) else error.code)
|
||||
print(f"{label} attempt {attempt}/5 failed", flush=True)
|
||||
backoff(attempt * 5)
|
||||
print(f"{label} failed after 5 attempts", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
for attempt in range(1, 6 if reset else 2):
|
||||
try:
|
||||
checkout()
|
||||
if reset:
|
||||
print(f"{label} attempt {attempt}/5 succeeded", flush=True)
|
||||
if kind == "clawhub":
|
||||
print(f"{label} completed in {int(time.monotonic() - started_at)}s", flush=True)
|
||||
raise SystemExit(0)
|
||||
except (FetchTimeout, GitFailure) as error:
|
||||
# Only command failures are retryable. Ownership/inspection errors
|
||||
# escape to the fail-closed boundary below, never workspace deletion.
|
||||
check_cancelled()
|
||||
if not reset:
|
||||
raise SystemExit(124 if isinstance(error, FetchTimeout) else error.code)
|
||||
print(f"{label} attempt {attempt}/5 failed", flush=True)
|
||||
backoff(attempt * 5)
|
||||
print(f"{label} failed after 5 attempts", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
checkout_environment.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
10
.github/actions/git-owner/test-prerequisites.json
vendored
Normal file
10
.github/actions/git-owner/test-prerequisites.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"outboundMessageTerminalReader": {
|
||||
"commit": "5dc4cf602bc5e263e83cd16a12bb1e100544f4c3",
|
||||
"file": "src/audit/message-delivery-progress-store.test.ts",
|
||||
"configs": [
|
||||
"test/vitest/vitest.unit-src.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-src.config.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/actions/git-owner/test-prerequisites.mjs
vendored
Normal file
21
.github/actions/git-owner/test-prerequisites.mjs
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { matchesGlob } from "node:path";
|
||||
import prerequisites from "./test-prerequisites.json" with { type: "json" };
|
||||
|
||||
// The trusted workflow owns immutable history; selected targets only decide
|
||||
// which readers run. Do not make credential-free test workers fetch it later.
|
||||
export function resolveTestGitCommits(shard) {
|
||||
const plans = shard.groups ?? [shard];
|
||||
return Object.values(prerequisites)
|
||||
.filter(
|
||||
({ file, configs }) =>
|
||||
existsSync(file) &&
|
||||
plans.some((plan) => {
|
||||
const patterns = plan.targets ?? plan.includePatterns;
|
||||
return patterns
|
||||
? patterns.some((pattern) => matchesGlob(file, pattern))
|
||||
: plan.configs?.some((config) => configs.includes(config));
|
||||
}),
|
||||
)
|
||||
.map(({ commit }) => commit);
|
||||
}
|
||||
14
.github/workflows/ci-build-artifacts-testbox.yml
vendored
14
.github/workflows/ci-build-artifacts-testbox.yml
vendored
|
|
@ -155,16 +155,26 @@ jobs:
|
|||
packages/*/dist/
|
||||
key: ${{ runner.os }}-dist-build-v2-${{ github.sha }}
|
||||
|
||||
- name: Resolve PR Testbox base
|
||||
if: github.event_name == 'pull_request'
|
||||
id: testbox-base
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# The shallow merge checkout already contains its actual base parent.
|
||||
base="$(node scripts/lib/merge-head-diff-base.mjs --base "$BASE_SHA" --head HEAD --prefer-first-parent)"
|
||||
echo "sha=$base" >> "$GITHUB_OUTPUT"
|
||||
- name: Ensure Testbox base commit
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event.pull_request.base.sha }}
|
||||
base-sha: ${{ steps.testbox-base.outputs.sha }}
|
||||
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
||||
- name: Prepare Testbox shell
|
||||
uses: ./.github/actions/prepare-testbox-shell
|
||||
with:
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}
|
||||
base-ref: ${{ steps.testbox-base.outputs.sha || 'refs/remotes/origin/main' }}
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
|
|
|
|||
14
.github/workflows/ci-check-arm-testbox.yml
vendored
14
.github/workflows/ci-check-arm-testbox.yml
vendored
|
|
@ -62,16 +62,26 @@ jobs:
|
|||
cache-mode: restore
|
||||
install-bun: "false"
|
||||
install-trufflehog: "true"
|
||||
- name: Resolve PR Testbox base
|
||||
if: github.event_name == 'pull_request'
|
||||
id: testbox-base
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# The shallow merge checkout already contains its actual base parent.
|
||||
base="$(node scripts/lib/merge-head-diff-base.mjs --base "$BASE_SHA" --head HEAD --prefer-first-parent)"
|
||||
echo "sha=$base" >> "$GITHUB_OUTPUT"
|
||||
- name: Ensure Testbox base commit
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event.pull_request.base.sha }}
|
||||
base-sha: ${{ steps.testbox-base.outputs.sha }}
|
||||
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
||||
- name: Prepare Testbox shell
|
||||
uses: ./.github/actions/prepare-testbox-shell
|
||||
with:
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}
|
||||
base-ref: ${{ steps.testbox-base.outputs.sha || 'refs/remotes/origin/main' }}
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
|
|
|
|||
14
.github/workflows/ci-check-testbox.yml
vendored
14
.github/workflows/ci-check-testbox.yml
vendored
|
|
@ -57,16 +57,26 @@ jobs:
|
|||
install-trufflehog: "true"
|
||||
# Testbox hydration uses the ordinary pnpm store cache. Canonical CI
|
||||
# owns the exact dependency archive and never delegates here.
|
||||
- name: Resolve PR Testbox base
|
||||
if: github.event_name == 'pull_request'
|
||||
id: testbox-base
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# The shallow merge checkout already contains its actual base parent.
|
||||
base="$(node scripts/lib/merge-head-diff-base.mjs --base "$BASE_SHA" --head HEAD --prefer-first-parent)"
|
||||
echo "sha=$base" >> "$GITHUB_OUTPUT"
|
||||
- name: Ensure Testbox base commit
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event.pull_request.base.sha }}
|
||||
base-sha: ${{ steps.testbox-base.outputs.sha }}
|
||||
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
||||
- name: Prepare Testbox shell
|
||||
uses: ./.github/actions/prepare-testbox-shell
|
||||
with:
|
||||
base-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'HEAD' }}
|
||||
base-ref: ${{ steps.testbox-base.outputs.sha || 'HEAD' }}
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
|
|
|
|||
89
.github/workflows/ci.yml
vendored
89
.github/workflows/ci.yml
vendored
|
|
@ -187,6 +187,7 @@ jobs:
|
|||
env:
|
||||
CHECKOUT_KIND: preflight
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
||||
CHECKOUT_EVENT_REF: ${{ github.ref }}
|
||||
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
||||
|
|
@ -207,6 +208,8 @@ jobs:
|
|||
}
|
||||
run_owner <<'PYTHON'
|
||||
# Generated from .github/actions/git-owner/owner.py; do not edit here.
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
|
|
@ -223,6 +226,7 @@ jobs:
|
|||
cancelled = 0
|
||||
closed = False
|
||||
git = shutil.which("git")
|
||||
checkout_environment = {}
|
||||
|
||||
|
||||
def cancel(signum, _frame):
|
||||
|
|
@ -408,6 +412,23 @@ jobs:
|
|||
return locks
|
||||
|
||||
|
||||
def git_auth_environment(remote, token):
|
||||
# Git's promisor fetch inherits this process-only config from checkout.
|
||||
# Reject redirects: http.<url> matching does not re-scope redirected requests.
|
||||
count = int(os.environ.get("GIT_CONFIG_COUNT", "0"))
|
||||
if count < 0:
|
||||
raise ValueError("Invalid Git environment configuration count")
|
||||
header = f"http.{remote}.extraheader"
|
||||
authorization = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
settings = [(header, ""), (header, f"AUTHORIZATION: basic {authorization}"),
|
||||
(f"http.{remote}.followRedirects", "false")]
|
||||
environment = {"GIT_CONFIG_COUNT": str(count + len(settings)), "GIT_TERMINAL_PROMPT": "0"}
|
||||
for index, (key, value) in enumerate(settings, count):
|
||||
environment[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
environment[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return environment
|
||||
|
||||
|
||||
def run_git(directory, *arguments, timeout=None, stdout=None, stderr=None, env=None,
|
||||
reclaim_locks=False):
|
||||
global closed
|
||||
|
|
@ -425,8 +446,10 @@ jobs:
|
|||
timed_out = False
|
||||
deadline = time.monotonic() + timeout if timeout is not None else None
|
||||
try:
|
||||
environment = ({**os.environ, **checkout_environment, **(env or {})}
|
||||
if checkout_environment or env is not None else None)
|
||||
options = {"stdin": subprocess.DEVNULL, "stdout": stdout, "stderr": stderr,
|
||||
"env": {**os.environ, **env} if env is not None else None}
|
||||
"env": environment}
|
||||
if os.name == "nt":
|
||||
job = create_job(None, None)
|
||||
limits = ExtendedLimits()
|
||||
|
|
@ -550,6 +573,14 @@ jobs:
|
|||
|
||||
def checkout():
|
||||
check_cancelled()
|
||||
prerequisites = json.loads(os.environ.get("CHECKOUT_GIT_COMMITS_JSON", "null")) if kind == "linux-node" else None
|
||||
if prerequisites is None:
|
||||
prerequisites = []
|
||||
if not isinstance(prerequisites, list) or any(
|
||||
not isinstance(commit, str) or not re.fullmatch("[0-9a-f]{40}", commit)
|
||||
for commit in prerequisites
|
||||
):
|
||||
raise ValueError("Invalid immutable test prerequisite commits")
|
||||
if reset:
|
||||
os.makedirs(workspace, exist_ok=True)
|
||||
# Every earlier Git group has been drained before deleting its workspace.
|
||||
|
|
@ -569,6 +600,9 @@ jobs:
|
|||
base = os.environ.get("CHECKOUT_BASE_SHA") if kind == "linux-node" else None
|
||||
if base:
|
||||
refs.append(f"+{base}:refs/remotes/origin/ci-ratchet-base")
|
||||
# Fetch full reader objects with the authenticated checkout, before its
|
||||
# credential scope ends and test workers create historical worktrees.
|
||||
refs.extend(prerequisites)
|
||||
fetch(workspace, *refs, prune=True, max_attempts=1 if reset else 3,
|
||||
retry_codes=(124, 137) if kind == "skills" else ())
|
||||
run_git(workspace, "checkout", *(["--force"] if reset else []), "--detach",
|
||||
|
|
@ -631,29 +665,37 @@ jobs:
|
|||
raise SystemExit(0)
|
||||
workspace = os.environ["GITHUB_WORKSPACE"]
|
||||
remote = f"https://github.com/{os.environ['CHECKOUT_REPO']}.git"
|
||||
# The workflow's token is repository-bound; never lend it to a sibling checkout.
|
||||
token = os.environ.pop("CHECKOUT_TOKEN", "")
|
||||
if token and os.environ["CHECKOUT_REPO"] == os.environ.get("GITHUB_REPOSITORY"):
|
||||
checkout_environment.update(git_auth_environment(remote, token))
|
||||
del token
|
||||
if kind == "clawhub":
|
||||
workspace = os.path.join(workspace, "clawhub-source")
|
||||
reset = kind in ("linux-node", "android", "clawhub")
|
||||
label = "ClawHub checkout" if kind == "clawhub" else "checkout"
|
||||
started_at = time.monotonic()
|
||||
for attempt in range(1, 6 if reset else 2):
|
||||
try:
|
||||
checkout()
|
||||
if reset:
|
||||
print(f"{label} attempt {attempt}/5 succeeded", flush=True)
|
||||
if kind == "clawhub":
|
||||
print(f"{label} completed in {int(time.monotonic() - started_at)}s", flush=True)
|
||||
raise SystemExit(0)
|
||||
except (FetchTimeout, GitFailure) as error:
|
||||
# Only command failures are retryable. Ownership/inspection errors
|
||||
# escape to the fail-closed boundary below, never workspace deletion.
|
||||
check_cancelled()
|
||||
if not reset:
|
||||
raise SystemExit(124 if isinstance(error, FetchTimeout) else error.code)
|
||||
print(f"{label} attempt {attempt}/5 failed", flush=True)
|
||||
backoff(attempt * 5)
|
||||
print(f"{label} failed after 5 attempts", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
for attempt in range(1, 6 if reset else 2):
|
||||
try:
|
||||
checkout()
|
||||
if reset:
|
||||
print(f"{label} attempt {attempt}/5 succeeded", flush=True)
|
||||
if kind == "clawhub":
|
||||
print(f"{label} completed in {int(time.monotonic() - started_at)}s", flush=True)
|
||||
raise SystemExit(0)
|
||||
except (FetchTimeout, GitFailure) as error:
|
||||
# Only command failures are retryable. Ownership/inspection errors
|
||||
# escape to the fail-closed boundary below, never workspace deletion.
|
||||
check_cancelled()
|
||||
if not reset:
|
||||
raise SystemExit(124 if isinstance(error, FetchTimeout) else error.code)
|
||||
print(f"{label} attempt {attempt}/5 failed", flush=True)
|
||||
backoff(attempt * 5)
|
||||
print(f"{label} failed after 5 attempts", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
checkout_environment.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
@ -987,6 +1029,7 @@ jobs:
|
|||
fi
|
||||
node "${manifest_node_args[@]}" --input-type=module <<'EOF'
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolveTestGitCommits } from "./.ci-harness/.github/actions/git-owner/test-prerequisites.mjs";
|
||||
|
||||
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
||||
const checkoutRevision = process.env.OPENCLAW_CI_CHECKOUT_REVISION ?? "";
|
||||
|
|
@ -1321,6 +1364,7 @@ jobs:
|
|||
env: shard.env,
|
||||
includePatterns: shard.includePatterns,
|
||||
pretest_build_mode: shard.pretestBuildMode,
|
||||
git_commits: resolveTestGitCommits(shard),
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
timeout_minutes: shard.timeoutMinutes,
|
||||
|
|
@ -1532,6 +1576,7 @@ jobs:
|
|||
env:
|
||||
CHECKOUT_KIND: manual
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_REF: ${{ inputs.target_ref }}
|
||||
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
||||
run: *owned_checkout_run
|
||||
|
|
@ -1698,6 +1743,7 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: *owned_checkout_run
|
||||
|
|
@ -2882,6 +2928,8 @@ jobs:
|
|||
checks-node-core-test-nondist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHECKOUT_GIT_COMMITS_JSON: ${{ toJson(matrix.git_commits) }}
|
||||
name: ${{ matrix.check_name || 'checks-node-core-test-nondist-shard' }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
|
|
@ -3858,6 +3906,7 @@ jobs:
|
|||
env:
|
||||
CHECKOUT_KIND: skills
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: *owned_checkout_run
|
||||
|
||||
|
|
@ -3904,6 +3953,7 @@ jobs:
|
|||
name: Checkout
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: *owned_checkout_run
|
||||
|
|
@ -4750,6 +4800,7 @@ jobs:
|
|||
env:
|
||||
CHECKOUT_KIND: android
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: *owned_checkout_run
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ function bundledPluginFile(pluginId: string, relativePath: string, suffix = ""):
|
|||
// Package scripts, workflows, Docker scenarios, and documented maintainer commands invoke these
|
||||
// files by path. They are executable roots rather than importable library modules.
|
||||
const repositoryScriptEntries = [
|
||||
// CI imports this selector from its trusted harness inside an inline Node script.
|
||||
".github/actions/git-owner/test-prerequisites.mjs!",
|
||||
// setup-node-env invokes this helper from composite-action YAML.
|
||||
".github/actions/setup-node-env/dependency-fingerprint.mjs!",
|
||||
"apps/android/scripts/build-release-artifacts.ts!",
|
||||
|
|
|
|||
305
extensions/anthropic/auth.runtime.ts
Normal file
305
extensions/anthropic/auth.runtime.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/** Auth execution stays deferred until a setup or doctor hook is invoked. */
|
||||
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
type AuthProfileStore,
|
||||
buildTokenProfileId,
|
||||
listProfilesForProvider,
|
||||
type OpenClawConfig as ProviderAuthConfig,
|
||||
type ProviderAuthResult,
|
||||
suggestOAuthProfileIdForLegacyDefault,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
|
||||
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
|
||||
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
|
||||
>[0];
|
||||
|
||||
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
|
||||
"Anthropic setup-token auth is supported in OpenClaw.",
|
||||
"OpenClaw prefers the native Claude CLI runtime when it is available on the host.",
|
||||
"Anthropic staff told us this OpenClaw path is allowed again.",
|
||||
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
|
||||
] as const;
|
||||
|
||||
function normalizeAnthropicSetupTokenInput(value: string): string {
|
||||
return value.replaceAll(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenProfileId(rawProfileId?: unknown): string {
|
||||
if (typeof rawProfileId === "string") {
|
||||
const trimmed = rawProfileId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
if (trimmed.startsWith(`${PROVIDER_ID}:`)) {
|
||||
return trimmed;
|
||||
}
|
||||
return buildTokenProfileId({ provider: PROVIDER_ID, name: trimmed });
|
||||
}
|
||||
}
|
||||
return `${PROVIDER_ID}:default`;
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | undefined {
|
||||
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveExpiresAtMsFromDurationMs(
|
||||
parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAnthropicSetupTokenAuth(
|
||||
ctx: ProviderAuthContext,
|
||||
defaultModel: string,
|
||||
): Promise<ProviderAuthResult> {
|
||||
const providedToken =
|
||||
typeof ctx.opts?.token === "string" && ctx.opts.token.trim().length > 0
|
||||
? normalizeAnthropicSetupTokenInput(ctx.opts.token)
|
||||
: undefined;
|
||||
const token =
|
||||
providedToken ??
|
||||
normalizeAnthropicSetupTokenInput(
|
||||
await ctx.prompter.text({
|
||||
message: "Paste Anthropic setup-token",
|
||||
validate: (value) => validateAnthropicSetupToken(normalizeAnthropicSetupTokenInput(value)),
|
||||
}),
|
||||
);
|
||||
const tokenError = validateAnthropicSetupToken(token);
|
||||
if (tokenError) {
|
||||
throw new Error(tokenError);
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts?.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts?.tokenExpiresIn);
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel,
|
||||
notes: [...ANTHROPIC_SETUP_TOKEN_NOTE_LINES],
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAnthropicSetupTokenNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveValidationContext,
|
||||
): string | null {
|
||||
if (ctx.opts.secretInputMode === "ref") {
|
||||
ctx.runtime.error(
|
||||
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const rawToken =
|
||||
typeof ctx.opts.token === "string" ? normalizeAnthropicSetupTokenInput(ctx.opts.token) : "";
|
||||
const tokenError = validateAnthropicSetupToken(rawToken);
|
||||
if (tokenError) {
|
||||
ctx.runtime.error(
|
||||
["Anthropic setup-token auth requires --token with a valid setup-token.", tokenError].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
|
||||
} catch (error) {
|
||||
ctx.runtime.error(
|
||||
`Invalid --token-expires-in: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
export async function runAnthropicSetupTokenNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveContext,
|
||||
defaultModel: string,
|
||||
): Promise<ProviderAuthConfig | null> {
|
||||
const rawToken = validateAnthropicSetupTokenNonInteractive(ctx);
|
||||
if (!rawToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token: rawToken,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[0]);
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[1]);
|
||||
|
||||
const withProfile = applyAuthProfileConfig(ctx.config, {
|
||||
profileId,
|
||||
provider: PROVIDER_ID,
|
||||
mode: "token",
|
||||
});
|
||||
const existingModelConfig =
|
||||
withProfile.agents?.defaults?.model && typeof withProfile.agents.defaults.model === "object"
|
||||
? withProfile.agents.defaults.model
|
||||
: {};
|
||||
return {
|
||||
...withProfile,
|
||||
agents: {
|
||||
...withProfile.agents,
|
||||
defaults: {
|
||||
...withProfile.agents?.defaults,
|
||||
model: {
|
||||
...existingModelConfig,
|
||||
primary: defaultModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicAuthDoctorHint(params: {
|
||||
config?: ProviderAuthContext["config"];
|
||||
store: AuthProfileStore;
|
||||
profileId?: string;
|
||||
}): string {
|
||||
const legacyProfileId = params.profileId ?? "anthropic:default";
|
||||
const suggested = suggestOAuthProfileIdForLegacyDefault({
|
||||
cfg: params.config,
|
||||
store: params.store,
|
||||
provider: PROVIDER_ID,
|
||||
legacyProfileId,
|
||||
});
|
||||
if (!suggested || suggested === legacyProfileId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const storeOauthProfiles = listProfilesForProvider(params.store, PROVIDER_ID)
|
||||
.filter((id) => params.store.profiles[id]?.type === "oauth")
|
||||
.join(", ");
|
||||
|
||||
const cfgMode = params.config?.auth?.profiles?.[legacyProfileId]?.mode;
|
||||
const cfgProvider = params.config?.auth?.profiles?.[legacyProfileId]?.provider;
|
||||
|
||||
return [
|
||||
"Doctor hint (for GitHub issue):",
|
||||
`- provider: ${PROVIDER_ID}`,
|
||||
`- config: ${legacyProfileId}${
|
||||
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
|
||||
}`,
|
||||
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
|
||||
`- suggested profile: ${suggested}`,
|
||||
`Fix: run "${formatCliCommand("openclaw doctor --yes")}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runAnthropicCliMigration(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<ProviderAuthResult> {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(ctx.env ?? process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
throw new Error(
|
||||
[
|
||||
"Claude CLI is not authenticated on this host.",
|
||||
`Run ${formatCliCommand("claude auth login")} first, then re-run this setup.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return buildAnthropicCliMigrationResult(ctx.config);
|
||||
}
|
||||
|
||||
export async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
config: ProviderAuthContext["config"];
|
||||
runtime: ProviderAuthContext["runtime"];
|
||||
agentDir?: string;
|
||||
}): Promise<ProviderAuthContext["config"] | null> {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
const error =
|
||||
authStatus.status === "unreadable"
|
||||
? [
|
||||
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
|
||||
`Run ${formatCliCommand("claude auth status")}, then retry.`,
|
||||
]
|
||||
: [
|
||||
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
|
||||
`Run ${formatCliCommand("claude auth login")} first.`,
|
||||
];
|
||||
ctx.runtime.error(error.join("\n"));
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = buildAnthropicCliMigrationResult(ctx.config);
|
||||
const currentDefaults = ctx.config.agents?.defaults;
|
||||
const currentModel = currentDefaults?.model;
|
||||
const currentFallbacks =
|
||||
currentModel && typeof currentModel === "object" && "fallbacks" in currentModel
|
||||
? currentModel.fallbacks
|
||||
: undefined;
|
||||
const migratedModel = result.configPatch?.agents?.defaults?.model;
|
||||
const migratedFallbacks =
|
||||
migratedModel && typeof migratedModel === "object" && "fallbacks" in migratedModel
|
||||
? migratedModel.fallbacks
|
||||
: undefined;
|
||||
const nextFallbacks = Array.isArray(migratedFallbacks) ? migratedFallbacks : currentFallbacks;
|
||||
|
||||
return {
|
||||
...ctx.config,
|
||||
...result.configPatch,
|
||||
agents: {
|
||||
...ctx.config.agents,
|
||||
...result.configPatch?.agents,
|
||||
defaults: {
|
||||
...currentDefaults,
|
||||
...result.configPatch?.agents?.defaults,
|
||||
model: {
|
||||
...(Array.isArray(nextFallbacks) ? { fallbacks: nextFallbacks } : {}),
|
||||
primary: result.defaultModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropicCliAuthProbe(env: NodeJS.ProcessEnv): {
|
||||
command: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const backend = buildAnthropicCliBackend().config;
|
||||
const probeEnv = { ...env, ...backend.env };
|
||||
for (const name of backend.clearEnv ?? []) {
|
||||
delete probeEnv[name];
|
||||
}
|
||||
return { command: backend.command, env: probeEnv };
|
||||
}
|
||||
|
|
@ -2,35 +2,19 @@
|
|||
* Anthropic provider runtime registration. It owns API-key/setup-token/Claude
|
||||
* CLI auth, dynamic model normalization, usage auth, media, and stream wrappers.
|
||||
*/
|
||||
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createLazyRuntimeMethod, createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderNormalizeResolvedModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
type AuthProfileStore,
|
||||
buildTokenProfileId,
|
||||
createProviderApiKeyAuthMethod,
|
||||
listProfilesForProvider,
|
||||
type OpenClawConfig as ProviderAuthConfig,
|
||||
type ProviderAuthResult,
|
||||
suggestOAuthProfileIdForLegacyDefault,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import {
|
||||
buildManifestModelProviderConfig,
|
||||
type ProviderCatalogResult,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-entry";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
cloneFirstTemplateModel,
|
||||
|
|
@ -49,7 +33,6 @@ import {
|
|||
supportsClaudeNativeXhighEffort,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
|
||||
import {
|
||||
|
|
@ -57,7 +40,6 @@ import {
|
|||
CLAUDE_CLI_OFF_THINKING_PROFILE,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
} from "./cli-constants.js";
|
||||
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
|
||||
import {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
|
||||
|
|
@ -78,9 +60,13 @@ import {
|
|||
import { isAnthropicOAuthApiKey, wrapAnthropicProviderStream } from "./stream-wrappers.js";
|
||||
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
|
||||
|
||||
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
|
||||
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
|
||||
>[0];
|
||||
// Registration needs descriptors, not auth persistence or external credential discovery.
|
||||
const loadAuthRuntime = createLazyRuntimeModule(() => import("./auth.runtime.js"));
|
||||
// Static registration must not initialize live catalog transport and policy.
|
||||
const buildOpenAICompatibleProviderCatalog = createLazyRuntimeMethod(
|
||||
createLazyRuntimeModule(() => import("openclaw/plugin-sdk/provider-catalog-live-runtime")),
|
||||
(runtime) => runtime.buildOpenAICompatibleProviderCatalog,
|
||||
);
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
|
||||
|
|
@ -130,13 +116,6 @@ const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
|
|||
] as const;
|
||||
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
|
||||
"Anthropic setup-token auth is supported in OpenClaw.",
|
||||
"OpenClaw prefers the native Claude CLI runtime when it is available on the host.",
|
||||
"Anthropic staff told us this OpenClaw path is allowed again.",
|
||||
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
|
||||
] as const;
|
||||
|
||||
function buildAnthropicCatalogProvider() {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: PROVIDER_ID,
|
||||
|
|
@ -200,152 +179,6 @@ const CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS = CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS.ma
|
|||
: ref,
|
||||
);
|
||||
|
||||
function normalizeAnthropicSetupTokenInput(value: string): string {
|
||||
return value.replaceAll(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenProfileId(rawProfileId?: unknown): string {
|
||||
if (typeof rawProfileId === "string") {
|
||||
const trimmed = rawProfileId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
if (trimmed.startsWith(`${PROVIDER_ID}:`)) {
|
||||
return trimmed;
|
||||
}
|
||||
return buildTokenProfileId({ provider: PROVIDER_ID, name: trimmed });
|
||||
}
|
||||
}
|
||||
return `${PROVIDER_ID}:default`;
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | undefined {
|
||||
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveExpiresAtMsFromDurationMs(
|
||||
parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
const providedToken =
|
||||
typeof ctx.opts?.token === "string" && ctx.opts.token.trim().length > 0
|
||||
? normalizeAnthropicSetupTokenInput(ctx.opts.token)
|
||||
: undefined;
|
||||
const token =
|
||||
providedToken ??
|
||||
normalizeAnthropicSetupTokenInput(
|
||||
await ctx.prompter.text({
|
||||
message: "Paste Anthropic setup-token",
|
||||
validate: (value) => validateAnthropicSetupToken(normalizeAnthropicSetupTokenInput(value)),
|
||||
}),
|
||||
);
|
||||
const tokenError = validateAnthropicSetupToken(token);
|
||||
if (tokenError) {
|
||||
throw new Error(tokenError);
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts?.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts?.tokenExpiresIn);
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: DEFAULT_ANTHROPIC_MODEL,
|
||||
notes: [...ANTHROPIC_SETUP_TOKEN_NOTE_LINES],
|
||||
};
|
||||
}
|
||||
|
||||
function validateAnthropicSetupTokenNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveValidationContext,
|
||||
): string | null {
|
||||
if (ctx.opts.secretInputMode === "ref") {
|
||||
ctx.runtime.error(
|
||||
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const rawToken =
|
||||
typeof ctx.opts.token === "string" ? normalizeAnthropicSetupTokenInput(ctx.opts.token) : "";
|
||||
const tokenError = validateAnthropicSetupToken(rawToken);
|
||||
if (tokenError) {
|
||||
ctx.runtime.error(
|
||||
["Anthropic setup-token auth requires --token with a valid setup-token.", tokenError].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
|
||||
} catch (error) {
|
||||
ctx.runtime.error(
|
||||
`Invalid --token-expires-in: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
async function runAnthropicSetupTokenNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveContext,
|
||||
): Promise<ProviderAuthConfig | null> {
|
||||
const rawToken = validateAnthropicSetupTokenNonInteractive(ctx);
|
||||
if (!rawToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token: rawToken,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[0]);
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[1]);
|
||||
|
||||
const withProfile = applyAuthProfileConfig(ctx.config, {
|
||||
profileId,
|
||||
provider: PROVIDER_ID,
|
||||
mode: "token",
|
||||
});
|
||||
const existingModelConfig =
|
||||
withProfile.agents?.defaults?.model && typeof withProfile.agents.defaults.model === "object"
|
||||
? withProfile.agents.defaults.model
|
||||
: {};
|
||||
return {
|
||||
...withProfile,
|
||||
agents: {
|
||||
...withProfile.agents,
|
||||
defaults: {
|
||||
...withProfile.agents?.defaults,
|
||||
model: {
|
||||
...existingModelConfig,
|
||||
primary: DEFAULT_ANTHROPIC_MODEL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropic46ForwardCompatModel(params: {
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
dashModelId: string;
|
||||
|
|
@ -936,124 +769,6 @@ function normalizeAnthropicResolvedModel(
|
|||
return pricingModel === ctx.model ? undefined : pricingModel;
|
||||
}
|
||||
|
||||
function buildAnthropicAuthDoctorHint(params: {
|
||||
config?: ProviderAuthContext["config"];
|
||||
store: AuthProfileStore;
|
||||
profileId?: string;
|
||||
}): string {
|
||||
const legacyProfileId = params.profileId ?? "anthropic:default";
|
||||
const suggested = suggestOAuthProfileIdForLegacyDefault({
|
||||
cfg: params.config,
|
||||
store: params.store,
|
||||
provider: PROVIDER_ID,
|
||||
legacyProfileId,
|
||||
});
|
||||
if (!suggested || suggested === legacyProfileId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const storeOauthProfiles = listProfilesForProvider(params.store, PROVIDER_ID)
|
||||
.filter((id) => params.store.profiles[id]?.type === "oauth")
|
||||
.join(", ");
|
||||
|
||||
const cfgMode = params.config?.auth?.profiles?.[legacyProfileId]?.mode;
|
||||
const cfgProvider = params.config?.auth?.profiles?.[legacyProfileId]?.provider;
|
||||
|
||||
return [
|
||||
"Doctor hint (for GitHub issue):",
|
||||
`- provider: ${PROVIDER_ID}`,
|
||||
`- config: ${legacyProfileId}${
|
||||
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
|
||||
}`,
|
||||
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
|
||||
`- suggested profile: ${suggested}`,
|
||||
`Fix: run "${formatCliCommand("openclaw doctor --yes")}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(ctx.env ?? process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
throw new Error(
|
||||
[
|
||||
"Claude CLI is not authenticated on this host.",
|
||||
`Run ${formatCliCommand("claude auth login")} first, then re-run this setup.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return buildAnthropicCliMigrationResult(ctx.config);
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
config: ProviderAuthContext["config"];
|
||||
runtime: ProviderAuthContext["runtime"];
|
||||
agentDir?: string;
|
||||
}): Promise<ProviderAuthContext["config"] | null> {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
const error =
|
||||
authStatus.status === "unreadable"
|
||||
? [
|
||||
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
|
||||
`Run ${formatCliCommand("claude auth status")}, then retry.`,
|
||||
]
|
||||
: [
|
||||
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
|
||||
`Run ${formatCliCommand("claude auth login")} first.`,
|
||||
];
|
||||
ctx.runtime.error(error.join("\n"));
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = buildAnthropicCliMigrationResult(ctx.config);
|
||||
const currentDefaults = ctx.config.agents?.defaults;
|
||||
const currentModel = currentDefaults?.model;
|
||||
const currentFallbacks =
|
||||
currentModel && typeof currentModel === "object" && "fallbacks" in currentModel
|
||||
? currentModel.fallbacks
|
||||
: undefined;
|
||||
const migratedModel = result.configPatch?.agents?.defaults?.model;
|
||||
const migratedFallbacks =
|
||||
migratedModel && typeof migratedModel === "object" && "fallbacks" in migratedModel
|
||||
? migratedModel.fallbacks
|
||||
: undefined;
|
||||
const nextFallbacks = Array.isArray(migratedFallbacks) ? migratedFallbacks : currentFallbacks;
|
||||
|
||||
return {
|
||||
...ctx.config,
|
||||
...result.configPatch,
|
||||
agents: {
|
||||
...ctx.config.agents,
|
||||
...result.configPatch?.agents,
|
||||
defaults: {
|
||||
...currentDefaults,
|
||||
...result.configPatch?.agents?.defaults,
|
||||
model: {
|
||||
...(Array.isArray(nextFallbacks) ? { fallbacks: nextFallbacks } : {}),
|
||||
primary: result.defaultModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropicCliAuthProbe(env: NodeJS.ProcessEnv): {
|
||||
command: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const backend = buildAnthropicCliBackend().config;
|
||||
const probeEnv = { ...env, ...backend.env };
|
||||
for (const name of backend.clearEnv ?? []) {
|
||||
delete probeEnv[name];
|
||||
}
|
||||
return { command: backend.command, env: probeEnv };
|
||||
}
|
||||
|
||||
/** Build the full Anthropic provider descriptor used by runtime registration. */
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
const providerId = "anthropic";
|
||||
|
|
@ -1091,9 +806,12 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
|||
message: "Claude CLI models",
|
||||
},
|
||||
},
|
||||
run: async (ctx: ProviderAuthContext) => await runAnthropicCliMigration(ctx),
|
||||
run: async (ctx: ProviderAuthContext) =>
|
||||
await (await loadAuthRuntime()).runAnthropicCliMigration(ctx),
|
||||
runNonInteractive: async (ctx) =>
|
||||
await runAnthropicCliMigrationNonInteractive({
|
||||
await (
|
||||
await loadAuthRuntime()
|
||||
).runAnthropicCliMigrationNonInteractive({
|
||||
config: ctx.config,
|
||||
runtime: ctx.runtime,
|
||||
agentDir: ctx.agentDir,
|
||||
|
|
@ -1113,11 +831,14 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
|||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key + token",
|
||||
},
|
||||
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupTokenAuth(ctx),
|
||||
run: async (ctx: ProviderAuthContext) =>
|
||||
await (await loadAuthRuntime()).runAnthropicSetupTokenAuth(ctx, defaultAnthropicModel),
|
||||
validateNonInteractive: async (ctx) =>
|
||||
Boolean(validateAnthropicSetupTokenNonInteractive(ctx)),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
await runAnthropicSetupTokenNonInteractive(ctx),
|
||||
Boolean((await loadAuthRuntime()).validateAnthropicSetupTokenNonInteractive(ctx)),
|
||||
runNonInteractive: async (ctx) =>
|
||||
await (
|
||||
await loadAuthRuntime()
|
||||
).runAnthropicSetupTokenNonInteractive(ctx, defaultAnthropicModel),
|
||||
},
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId,
|
||||
|
|
@ -1209,8 +930,8 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
|||
resolveUsageAuth: resolveAnthropicUsageAuth,
|
||||
fetchUsageSnapshot: fetchAnthropicUsage,
|
||||
isCacheTtlEligible: () => true,
|
||||
buildAuthDoctorHint: (ctx) =>
|
||||
buildAnthropicAuthDoctorHint({
|
||||
buildAuthDoctorHint: async (ctx) =>
|
||||
(await loadAuthRuntime()).buildAnthropicAuthDoctorHint({
|
||||
config: ctx.config,
|
||||
store: ctx.store,
|
||||
profileId: ctx.profileId,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
ProviderResolveUsageAuthContext,
|
||||
ProviderResolvedUsageAuth,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { validateAnthropicSetupToken } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
addProviderUsageModel,
|
||||
asProviderUsageObject,
|
||||
|
|
@ -262,8 +261,11 @@ export async function resolveAnthropicUsageAuth(
|
|||
if (adminKey) {
|
||||
return { token: encodeAdminToken(adminKey) };
|
||||
}
|
||||
if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) {
|
||||
return { token: apiKey };
|
||||
if (apiKey) {
|
||||
const { validateAnthropicSetupToken } = await import("openclaw/plugin-sdk/provider-auth");
|
||||
if (validateAnthropicSetupToken(apiKey) === undefined) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
}
|
||||
|
||||
// Claude owns its native refresh-token family. Do not resolve a copied
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe
|
|||
|
||||
## PR Prepare Gates
|
||||
|
||||
- Main freshness belongs to one `scripts/pr` operation: nested entry/review guards share a captured main SHA; gate selection, publication after gates, and merge verification after CI each refresh it. Capture the canonical origin's main from the PR worktree's private `FETCH_HEAD`, never a later shared-ref read. Cold provisioning adds one canonical bootstrap fetch and fully initializes from `refs/remotes/origin/main` before the private fetch; that shared seed is not checkpoint authority. Every standalone command and newly provisioned worktree starts fresh; coalescing must not skip containment, transition recovery, exact-head checks, or the separate Crabbox authority windows.
|
||||
- Main freshness belongs to one `scripts/pr` operation: nested entry/review guards share a captured main SHA; gate selection, publication after gates, and merge verification after CI each refresh it. Capture the canonical origin's main from the PR worktree's private `FETCH_HEAD`, never a later shared-ref read. Main refreshes never write shared `origin/main`. Cold provisioning fetches into its existing per-PR `temp/pr-<PR>` branch without writing canonical `FETCH_HEAD`, then fully initializes from that seed before the private checkpoint; the seed is not checkpoint authority. Every standalone command and newly provisioned worktree starts fresh; coalescing must not skip containment, transition recovery, exact-head checks, or the separate Crabbox authority windows.
|
||||
- `scripts/pr` serializes review, prepare, and merge operations per PR across linked worktrees; `scripts/pr gc` skips active or indeterminate locks. Its subcommand classification table is the canonical wrapper trust boundary: a mismatched local wrapper may run only a classified `advisory` subcommand with `--dev-wrapper` or `OPENCLAW_PR_DEV_WRAPPER=1`; classified `landing` subcommands always require canonical/origin-main wrapper code. A worktree whose wrapper differs from origin/main (stale base or wrapper-editing branch) loudly substitutes the canonical checkout's wrapper when that checkout is clean and byte-identical to fetched `refs/remotes/origin/main`; it refuses only when no anchor-matching wrapper is available. A successful command return is the trusted synchronous-completion contract: every PR-state-mutating child must be joined before returning, and such work must never daemonize or explicitly escape both the operation group and lock-notification FD. Release on clean exit requires the leader's completion marker; an escaped descendant that merely holds the notify pipe then produces a loud warned release instead of retention (#124583), while all failure shapes still retain. A failed command auto-releases only while its explicit pre-side-effect validation marker remains active; failures after mutation/tool launch, interruptions, and controller loss stay locked because detached children cannot be disproved. After verifying no child tools remain, use the reported exact-OID `scripts/pr lock-recover` command. Never bypass or delete these refs manually.
|
||||
- `OPENCLAW_PR_GATES_REMOTE=testbox` runs the full-suite `pnpm test` gate on a Blacksmith Testbox through `scripts/crabbox-wrapper.mjs` (same delegation as `check:changed`); `pnpm build`/`pnpm check` stay local. The `tbx_` lease id and Actions run URL land in `.local/gates.env` (`REMOTE_GATES_*`) and `.local/prep.md`. Use it for reviewed trusted code when a loaded host makes the local 88-shard run stall-kill; contributor/fork code stays on secretless CI or sanitized AWS unless a maintainer explicitly approves credentialed execution.
|
||||
- `OPENCLAW_PR_GATES_REMOTE=crabbox-aws` is an explicit active-org-admin fallback, never the default. `prepare-gates` records a pending handle; after `prepare-push` proves the exact remote head, `scripts/pr-lib/ci-dispatch.mjs --backend crabbox` synchronously dispatches the protected-main publisher and waits for its exact-head check. That trusted workflow checksum-installs released Crabbox v0.46, resolves its `/v1/whoami` service principal, and creates sanitized direct AWS proof under the same token with `umask 022`, trusted `scripts/crabbox-untrusted-bootstrap.sh`, `pnpm build`, `pnpm check`, and the fail-closed PR-derived test plan from the repository's changed-test owner. Every executable changed path must independently resolve to concrete matched test files; broad fallback, partial plans, deleted executable paths, and unmatched/config targets are refused. Only explicit docs and `AGENTS.md`/`CLAUDE.md` instruction surfaces may produce zero tests. The canonical broker command binds the exact PR base, head, bootstrap hash, and plan digest. The publisher requires the PR base to be the merge base of its immutable workflow SHA and proves that each protected-main snapshot is identical to or descended from that workflow SHA, with an unchanged reread around each comparison. Main may advance during the long remote run, but not inside either validation window. It validates its newly created immutable broker run, ordered complete events, exact broker-resolved owner/org correlation between `/v1/whoami` and the run, canonical bootstrap hash, exact command/base/head/plan, active admin actor, and open same-repository PR target before GitHub Actions adds the workflow SHA to the strict summary and publishes the distinct `openclaw/crabbox-gate`; draft rejection remains a merge-time rule. Only after that success does `.local/gates.env` record provider/run/lease/URL recovery metadata from the trusted check. Retained logs are checked when present but are optional because released v0.46 can retain zero log bytes for a successful run. Normal `openclaw/ci-gate` semantics stay unchanged. Native merge may add `--admin` only when the exact Crabbox check is successful from GitHub Actions, its immutable workflow SHA is an ancestor of a stable final protected-main snapshot, the actor is still an active organization admin, and the sole unsatisfied required check is a normal CI gate with GitHub-owned workflow `startup_failure` or a recognized hosted, unacquired, zero-step `failure`/`timed_out` job; cancellation, action-required, stale, an assigned runner, job log text, and any failed or executed workflow step never authorize bypass. The flow repeats this verification immediately before the pinned-head merge request; GitHub has no expected-base-OID merge precondition, so the Crabbox path compares the landed squash parent with that final main snapshot in `.local/merge-crabbox-parent-audit.json` and reports a match or intervening main movement after the completed merge. Normal merge paths do not perform this audit.
|
||||
|
|
|
|||
|
|
@ -227,13 +227,18 @@ checkout_pr_worktree_target() {
|
|||
}
|
||||
|
||||
fetch_canonical_main() {
|
||||
local root source git_dir
|
||||
local root source git_dir refspec=refs/heads/main
|
||||
local options=(--no-tags --refmap=)
|
||||
if [ -n "${1:-}" ]; then
|
||||
refspec="+$refspec:$1"
|
||||
options+=(--no-write-fetch-head)
|
||||
fi
|
||||
root=$(repo_root) || return 1
|
||||
source=$(git -C "$root" remote get-url origin) || return 1
|
||||
git_dir=$(git rev-parse --absolute-git-dir) || return 1
|
||||
# Resolve relative URLs at the canonical root; ignore worktree origin/refmaps.
|
||||
git -C "$root" --git-dir="$git_dir" fetch --no-tags --refmap= "$source" \
|
||||
+refs/heads/main:refs/remotes/origin/main
|
||||
# Other PRs and ordinary fetches own shared refs and the root FETCH_HEAD.
|
||||
git -C "$root" --git-dir="$git_dir" fetch "${options[@]}" "$source" "$refspec"
|
||||
}
|
||||
|
||||
refresh_main_snapshot() {
|
||||
|
|
@ -285,10 +290,10 @@ enter_worktree() {
|
|||
fi
|
||||
# Cold bootstrap needs one extra fetch before private FETCH_HEAD exists.
|
||||
# Initialize fully before the next network wait so interruption is retryable.
|
||||
# This shared main ref is only a seed, never the operation's snapshot.
|
||||
# The PR lock owns this existing temp branch, not shared origin/main or FETCH_HEAD.
|
||||
PR_MAIN_SHA=""
|
||||
fetch_canonical_main || return 1
|
||||
git -C "$root" worktree add -B "temp/pr-$pr" "$dir" refs/remotes/origin/main || return 1
|
||||
fetch_canonical_main "refs/heads/temp/pr-$pr" || return 1
|
||||
git -C "$root" worktree add -B "temp/pr-$pr" "$dir" "refs/heads/temp/pr-$pr" || return 1
|
||||
resolved_parent=$(resolve_existing_dir_path "$(dirname "$dir")") || return 1
|
||||
resolved_dir="$resolved_parent/pr-$pr"
|
||||
initialized_sha=$(git -C "$dir" rev-parse --verify HEAD) || return 1
|
||||
|
|
|
|||
|
|
@ -3038,7 +3038,7 @@ function resolveDirectToolingReferenceTests(changedPath: string, cwd: string) {
|
|||
|
||||
function resolveToolingTestTargets(changedPath: string, cwd = process.cwd()) {
|
||||
if (
|
||||
/^test\/scripts\/(?:ci-(?:checkout|git-owner|linux-git|platform-checkout)\.test(?:-support)?\.ts|generated-publisher\.test-support\.ts|openclaw-performance-(?:workflow\.test(?:-support)?|git-lifecycle\.test)\.ts|plugin-release-git-lifecycle\.test\.ts|release-workflow-git-lifecycle\.test\.ts|fixtures\/ci-platform-checkout\.mjs)$/u.test(
|
||||
/^test\/scripts\/(?:ci-(?:checkout|git-owner|linux-git|platform-checkout)\.test(?:-support)?\.ts|generated-publisher\.test-support\.ts|openclaw-performance-(?:workflow\.test(?:-support)?|git-lifecycle\.test)\.ts|plugin-release-git-lifecycle\.test\.ts|release-workflow-git-lifecycle\.test\.ts|fixtures\/(?:ci-platform-checkout\.mjs|ci-checkout-auth\.py))$/u.test(
|
||||
changedPath,
|
||||
)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import gitPrerequisites from "../../.github/actions/git-owner/test-prerequisites.json" with { type: "json" };
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_VERSION } from "../state/openclaw-state-db-contract.js";
|
||||
|
|
@ -30,7 +31,7 @@ import {
|
|||
} from "./message-delivery-progress-store.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const PINNED_PRE_C04_READER_SHA = "5dc4cf602bc5e263e83cd16a12bb1e100544f4c3";
|
||||
const PINNED_PRE_C04_READER_SHA = gitPrerequisites.outboundMessageTerminalReader.commit;
|
||||
const OUTBOUND_PROGRESS_PRUNE_BATCH_ROWS_CONTRACT = 1_024;
|
||||
|
||||
function ensurePinnedReaderCommit(repositoryRoot: string): void {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ const buildOpenAICompatibleProviderCatalog = createLazyRuntimeMethod(
|
|||
(runtime) => runtime.buildOpenAICompatibleProviderCatalog,
|
||||
);
|
||||
|
||||
// Auth descriptors are safe to construct before the lazy credential runtime is needed.
|
||||
export { createProviderApiKeyAuthMethod };
|
||||
|
||||
type ApiKeyAuthMethodOptions = Parameters<typeof createProviderApiKeyAuthMethod>[0];
|
||||
|
||||
type SingleProviderPluginManifestAuthChoice = Pick<
|
||||
|
|
|
|||
|
|
@ -202,9 +202,9 @@ export function describeAnthropicProviderRuntimeContract(
|
|||
});
|
||||
});
|
||||
|
||||
it("owns auth doctor hint generation", () => {
|
||||
it("owns auth doctor hint generation", async () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const hint = provider.buildAuthDoctorHint?.({
|
||||
const hint = await provider.buildAuthDoctorHint?.({
|
||||
provider: "anthropic",
|
||||
profileId: "anthropic:default",
|
||||
config: {
|
||||
|
|
|
|||
38
test/scripts/ci-git-owner-auth.test.ts
Normal file
38
test/scripts/ci-git-owner-auth.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["fetch-only", "checkout"])(
|
||||
"keeps checkout HTTP authentication transient and scoped (%s)",
|
||||
async (mode) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const code = await runManagedCommand({
|
||||
bin: "python3",
|
||||
args: [
|
||||
"-I",
|
||||
"-S",
|
||||
"test/scripts/fixtures/ci-checkout-auth.py",
|
||||
path.resolve(".github/actions/git-owner/owner.py"),
|
||||
mode,
|
||||
],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeoutMs: 30_000,
|
||||
timeoutKillGraceMs: 12_000,
|
||||
requireProcessTreeExit: true,
|
||||
onReady(child) {
|
||||
child.stdout?.on("data", (chunk) => (stdout += String(chunk)));
|
||||
child.stderr?.on("data", (chunk) => (stderr += String(chunk)));
|
||||
},
|
||||
});
|
||||
expect(code, stderr).toBe(0);
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
mode,
|
||||
fetchAuthenticated: true,
|
||||
missingBlobBeforeCheckout: true,
|
||||
lazyCheckoutSucceeded: mode === "checkout",
|
||||
credentialPersisted: false,
|
||||
});
|
||||
},
|
||||
50_000,
|
||||
);
|
||||
|
|
@ -42,11 +42,13 @@ const moved = "d".repeat(40);
|
|||
const merge = "e".repeat(40);
|
||||
const defaults: Record<string, string> = {
|
||||
CHECKOUT_REPO: "fixture/checkout",
|
||||
CHECKOUT_TOKEN: "",
|
||||
CHECKOUT_REF: candidate,
|
||||
CHECKOUT_SHA: candidate,
|
||||
CHECKOUT_FALLBACK_REF: candidate,
|
||||
CHECKOUT_EVENT_REF: "refs/heads/main",
|
||||
WORKFLOW_SHA: harness,
|
||||
CHECKOUT_GIT_COMMITS_JSON: "null",
|
||||
GITHUB_EVENT_NAME: "push",
|
||||
GITHUB_REPOSITORY: "fixture/checkout",
|
||||
DEFAULT_BRANCH: "main",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,31 @@ it("keeps exactly one byte-identical generated CI owner", () => {
|
|||
expect(body).toBe(source);
|
||||
});
|
||||
|
||||
it("binds read-only checkout authentication only to the workflow repository", () => {
|
||||
const workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8")) as {
|
||||
permissions: Record<string, string>;
|
||||
jobs: Record<
|
||||
string,
|
||||
{ permissions?: Record<string, string>; steps?: { env?: Record<string, string> }[] }
|
||||
>;
|
||||
};
|
||||
let ownedCheckouts = 0;
|
||||
for (const job of Object.values(workflow.jobs)) {
|
||||
for (const step of job.steps ?? []) {
|
||||
if (!step.env?.CHECKOUT_REPO) {
|
||||
continue;
|
||||
}
|
||||
ownedCheckouts++;
|
||||
const sameRepository = step.env.CHECKOUT_REPO === "${{ github.repository }}";
|
||||
expect(step.env.CHECKOUT_TOKEN).toBe(sameRepository ? "${{ github.token }}" : undefined);
|
||||
if (sameRepository) {
|
||||
expect((job.permissions ?? workflow.permissions).contents).toBe("read");
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(ownedCheckouts).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves linked Git metadata (reclaim locks=%s)", async (reclaimLocks) => {
|
||||
const invocation = reclaimLocks
|
||||
? 'run_git(os.getcwd(), "fetch", "origin", "fixture", reclaim_locks=True)'
|
||||
|
|
|
|||
91
test/scripts/ci-git-prerequisites.test.ts
Normal file
91
test/scripts/ci-git-prerequisites.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import prerequisites from "../../.github/actions/git-owner/test-prerequisites.json" with { type: "json" };
|
||||
import { resolveTestGitCommits } from "../../.github/actions/git-owner/test-prerequisites.mjs";
|
||||
import { createNodeTestShardBundles } from "../../scripts/lib/ci-node-test-plan.mts";
|
||||
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
|
||||
import { runCiGitStep } from "./ci-git-owner.test-support.js";
|
||||
|
||||
const reader = prerequisites.outboundMessageTerminalReader;
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"opens the pinned reader after checkout authentication has ended",
|
||||
async () => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const code = await runManagedCommand({
|
||||
bin: "python3",
|
||||
args: [
|
||||
"-I",
|
||||
"-S",
|
||||
"test/scripts/fixtures/ci-checkout-auth.py",
|
||||
path.resolve(".github/actions/git-owner/owner.py"),
|
||||
"historical",
|
||||
],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeoutMs: 30_000,
|
||||
timeoutKillGraceMs: 12_000,
|
||||
requireProcessTreeExit: true,
|
||||
onReady(child) {
|
||||
child.stdout?.on("data", (chunk) => (stdout += String(chunk)));
|
||||
child.stderr?.on("data", (chunk) => (stderr += String(chunk)));
|
||||
},
|
||||
});
|
||||
expect(code, stderr).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
historicalReaderPrepared: true,
|
||||
credentialPersisted: false,
|
||||
});
|
||||
},
|
||||
50_000,
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ targets: [reader.file] },
|
||||
{ configs: reader.configs },
|
||||
{ groups: [{ configs: reader.configs, includePatterns: ["src/audit/*.test.ts"] }] },
|
||||
])("selects immutable history for an owning test plan: %j", (plan) => {
|
||||
expect(resolveTestGitCommits(plan)).toEqual([reader.commit]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ targets: ["test/scripts/run-opengrep.test.ts"] },
|
||||
{ configs: ["test/vitest/vitest.agents-core.config.ts"] },
|
||||
{ groups: [{ configs: reader.configs, includePatterns: ["src/network/*.test.ts"] }] },
|
||||
{ groups: [] },
|
||||
])("does not fetch unrelated test history: %j", (plan) => {
|
||||
expect(resolveTestGitCommits(plan)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"prepares the reader only in its selected CI shard (compact=%s)",
|
||||
(compact) => {
|
||||
const shards = createNodeTestShardBundles({ compact });
|
||||
expect(shards.filter((shard) => resolveTestGitCommits(shard).length > 0)).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("fetches selected history with the initial checkout before the test worker runs", async () => {
|
||||
const report = await runCiGitStep({
|
||||
job: "checks-node-core-test-nondist-shard",
|
||||
env: { CHECKOUT_GIT_COMMITS_JSON: JSON.stringify([reader.commit]) },
|
||||
fetchResults: [0, 0],
|
||||
});
|
||||
expect(report.code, report.output).toBe(0);
|
||||
expect(report.fetches[0]?.args).toContain(reader.commit);
|
||||
expect(report.fetches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["{}", '"main"', '["--upload-pack=bad"]', '["abc"]', "[null]"])(
|
||||
"rejects malformed immutable history before checkout mutation: %s",
|
||||
async (input) => {
|
||||
const report = await runCiGitStep({
|
||||
job: "checks-node-core-test-nondist-shard",
|
||||
env: { CHECKOUT_GIT_COMMITS_JSON: input },
|
||||
fetchResults: [],
|
||||
});
|
||||
expect(report.code, report.output).toBe(125);
|
||||
expect(report.commands).toEqual([]);
|
||||
expect(report.output).toContain("Git ownership/setup failed (ValueError)");
|
||||
},
|
||||
);
|
||||
|
|
@ -292,6 +292,7 @@ function runCiManifestFixture(options: {
|
|||
nodeFastPluginContracts?: boolean;
|
||||
nodeFastCiRouting?: boolean;
|
||||
runNode?: boolean;
|
||||
historicalReader?: boolean;
|
||||
runnerBackend?: "blacksmith" | "github" | "hybrid";
|
||||
runnerProfile?: "blacksmith" | "github" | "hybrid";
|
||||
targetHostedRunnerProfileContract?: boolean;
|
||||
|
|
@ -463,6 +464,17 @@ function runCiManifestFixture(options: {
|
|||
].join("\n"),
|
||||
);
|
||||
const outputPath = path.join(root, "manifest.out");
|
||||
const gitOwner = ".github/actions/git-owner";
|
||||
const trustedGitOwner = path.join(root, ".ci-harness", gitOwner);
|
||||
mkdirSync(trustedGitOwner, { recursive: true });
|
||||
for (const name of ["test-prerequisites.mjs", "test-prerequisites.json"]) {
|
||||
writeFileSync(path.join(trustedGitOwner, name), readFileSync(path.join(gitOwner, name)));
|
||||
}
|
||||
if (options.historicalReader) {
|
||||
const reader = path.join(root, "src/audit/message-delivery-progress-store.test.ts");
|
||||
mkdirSync(path.dirname(reader), { recursive: true });
|
||||
writeFileSync(reader, "export {};\n");
|
||||
}
|
||||
writeFileSync(outputPath, "", "utf8");
|
||||
const manifestStep = readCiWorkflow().jobs.preflight.steps.find(
|
||||
(step: { name?: string }) => step.name === "Build CI manifest",
|
||||
|
|
@ -6507,17 +6519,17 @@ server.listen(0, "127.0.0.1", () => {
|
|||
[
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
"1",
|
||||
"${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'HEAD' }}",
|
||||
"${{ steps.testbox-base.outputs.sha || 'HEAD' }}",
|
||||
],
|
||||
[
|
||||
".github/workflows/ci-check-arm-testbox.yml",
|
||||
"0",
|
||||
"${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}",
|
||||
"${{ steps.testbox-base.outputs.sha || 'refs/remotes/origin/main' }}",
|
||||
],
|
||||
[
|
||||
".github/workflows/ci-build-artifacts-testbox.yml",
|
||||
"0",
|
||||
"${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}",
|
||||
"${{ steps.testbox-base.outputs.sha || 'refs/remotes/origin/main' }}",
|
||||
],
|
||||
] as const;
|
||||
|
||||
|
|
@ -6550,7 +6562,7 @@ server.listen(0, "127.0.0.1", () => {
|
|||
expect(ensureBaseStep?.if, workflowPath).toBe("github.event_name == 'pull_request'");
|
||||
expect(ensureBaseStep?.uses, workflowPath).toBe("./.github/actions/ensure-base-commit");
|
||||
expect(ensureBaseStep?.with, workflowPath).toEqual({
|
||||
"base-sha": "${{ github.event.pull_request.base.sha }}",
|
||||
"base-sha": "${{ steps.testbox-base.outputs.sha }}",
|
||||
"fetch-ref": "${{ github.event.pull_request.base.ref }}",
|
||||
});
|
||||
expect(JSON.stringify(job.steps), workflowPath).not.toContain(
|
||||
|
|
@ -7454,7 +7466,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
|||
expect(specifier, diagnostic).toMatch(/^\.\.?\//u);
|
||||
const importedFile = path.relative(
|
||||
repoRoot,
|
||||
path.resolve(workflow ? repoRoot : path.dirname(file), specifier),
|
||||
path.resolve(
|
||||
workflow ? repoRoot : path.dirname(file),
|
||||
// CI materializes trusted actions under the harness checkout prefix.
|
||||
workflow ? specifier.replace(/^\.\/\.ci-harness\//u, "./") : specifier,
|
||||
),
|
||||
);
|
||||
expect(importedFile, diagnostic).not.toMatch(/^(?:\.\.(?:[\\/]|$)|[\\/])/u);
|
||||
expect(importedFile.split(path.sep), diagnostic).not.toContain("node_modules");
|
||||
|
|
@ -8014,6 +8030,26 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
|||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"projects immutable reader history only when the selected target has the reader (present=%s)",
|
||||
(historicalReader) => {
|
||||
const manifest = runCiManifestFixture({
|
||||
bundledPlanner: true,
|
||||
changedPaths: ["src/audit/message-delivery-progress-store.test.ts"],
|
||||
eventName: "pull_request",
|
||||
historicalReader,
|
||||
});
|
||||
expect(manifest.status, manifest.output).toBe(0);
|
||||
const rows = JSON.parse(
|
||||
expectDefined(manifest.outputs.checks_node_core_nondist_matrix, "reader matrix"),
|
||||
).include;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].git_commits).toEqual(
|
||||
historicalReader ? ["5dc4cf602bc5e263e83cd16a12bb1e100544f4c3"] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses target-owned CI plans and capabilities for older release checkouts", () => {
|
||||
const androidRun = readCiWorkflow().jobs.android.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Run Android ${{ matrix.task }}",
|
||||
|
|
|
|||
310
test/scripts/fixtures/ci-checkout-auth.py
Normal file
310
test/scripts/fixtures/ci-checkout-auth.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Exercise the checkout owner's authentication against real smart HTTP Git."""
|
||||
import base64
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
owner = str(Path(sys.argv[1]).resolve())
|
||||
mode = sys.argv[2]
|
||||
git = shutil.which("git")
|
||||
assert git, "Git is required for checkout authentication proof"
|
||||
|
||||
def cancelled(signum, _frame):
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
signal.signal(signal.SIGTERM, cancelled)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="checkout-auth-") as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
# Only tool-location/OS variables enter the fixture; real Git credentials,
|
||||
# helpers, config overrides and tracing from the host must never participate.
|
||||
env = {key: os.environ[key] for key in ("PATH", "SystemRoot", "SYSTEMROOT", "WINDIR")
|
||||
if key in os.environ}
|
||||
env.update(HOME=str(home), USERPROFILE=str(home), XDG_CONFIG_HOME=str(home),
|
||||
GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
|
||||
GIT_TERMINAL_PROMPT="0", LC_ALL="C")
|
||||
|
||||
def command(*arguments, cwd=root, extra=None):
|
||||
return subprocess.run(arguments, cwd=cwd, env={**env, **(extra or {})},
|
||||
capture_output=True, text=True, timeout=20)
|
||||
|
||||
def checked(*arguments, **options):
|
||||
result = command(*arguments, **options)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
source = root / "source"
|
||||
checked(git, "init", "--initial-branch=main", str(source))
|
||||
(source / "payload.txt").write_text("checkout must hydrate this promised blob\n")
|
||||
setup_action = source / ".github/actions/setup-node-env/action.yml"
|
||||
setup_action.parent.mkdir(parents=True)
|
||||
setup_action.write_text("name: fixture\n")
|
||||
checked(git, "add", ".", cwd=source)
|
||||
checked(git, "-c", "user.name=Checkout Fixture", "-c", "user.email=fixture@example.invalid",
|
||||
"commit", "-m", "fixture", cwd=source)
|
||||
revision = checked(git, "rev-parse", "HEAD", cwd=source)
|
||||
historical_revision = revision
|
||||
if mode == "historical":
|
||||
(source / "payload.txt").write_text("current checkout\n")
|
||||
checked(git, "add", "payload.txt", cwd=source)
|
||||
checked(git, "-c", "user.name=Checkout Fixture", "-c", "user.email=fixture@example.invalid",
|
||||
"commit", "-m", "current", cwd=source)
|
||||
revision = checked(git, "rev-parse", "HEAD", cwd=source)
|
||||
blob = checked(git, "rev-parse", "HEAD:payload.txt", cwd=source)
|
||||
bare = root / "repo.git"
|
||||
checked(git, "clone", "--bare", str(source), str(bare))
|
||||
checked(git, "config", "uploadpack.allowFilter", "true", cwd=bare)
|
||||
checked(git, "config", "uploadpack.allowAnySHA1InWant", "true", cwd=bare)
|
||||
token = "synthetic-checkout-fixture"
|
||||
encoded = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
authorization = f"Basic {encoded}"
|
||||
requests = []
|
||||
redirect = False
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.serve_git()
|
||||
|
||||
def do_POST(self):
|
||||
self.serve_git()
|
||||
|
||||
def serve_git(self):
|
||||
parsed = urlsplit(self.path)
|
||||
headers = self.headers.get_all("Authorization") or []
|
||||
authenticated = len(headers) == 1 and headers[0].lower().startswith("basic ") and headers[0][6:] == encoded
|
||||
requests.append({"path": parsed.path, "authenticated": authenticated,
|
||||
"authorizationPresent": bool(headers)})
|
||||
if parsed.path.startswith("/other.git"):
|
||||
self.send_error(404)
|
||||
return
|
||||
if redirect:
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"/other.git/info/refs?{parsed.query}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
if not authenticated:
|
||||
self.send_response(401)
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="checkout fixture"')
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
backend = subprocess.run(
|
||||
[git, "http-backend"], input=body, capture_output=True, timeout=15,
|
||||
env={**env, "GIT_PROJECT_ROOT": str(root), "GIT_HTTP_EXPORT_ALL": "1",
|
||||
"PATH_INFO": parsed.path, "QUERY_STRING": parsed.query,
|
||||
"REQUEST_METHOD": self.command,
|
||||
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
|
||||
"CONTENT_LENGTH": str(len(body)),
|
||||
"HTTP_GIT_PROTOCOL": self.headers.get("Git-Protocol", "")})
|
||||
assert backend.returncode == 0, backend.stderr.decode()
|
||||
headers, separator, response = backend.stdout.partition(b"\r\n\r\n")
|
||||
if not separator:
|
||||
headers, separator, response = backend.stdout.partition(b"\n\n")
|
||||
assert separator, "Git HTTP backend omitted CGI headers"
|
||||
fields = [line.split(b":", 1) for line in headers.splitlines()]
|
||||
code = next((int(value.strip().split()[0]) for key, value in fields
|
||||
if key.lower() == b"status"), 200)
|
||||
self.send_response(code)
|
||||
for key, value in fields:
|
||||
if key.lower() != b"status":
|
||||
self.send_header(key.decode(), value.strip().decode())
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.start()
|
||||
try:
|
||||
remote = f"http://127.0.0.1:{server.server_port}/repo.git"
|
||||
workspace = root / "workspace"
|
||||
checked(git, "init", str(workspace))
|
||||
checked(git, "remote", "add", "origin", remote, cwd=workspace)
|
||||
policy = root / "policy.py"
|
||||
policy.write_text('''from ci_git_owner import run_git, git_output
|
||||
import json, os, sys
|
||||
remote, token, phase = sys.argv[3:]
|
||||
if phase == "fetch-only":
|
||||
import base64
|
||||
encoded = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
auth = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": f"http.{remote}.extraHeader",
|
||||
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {encoded}"}
|
||||
else:
|
||||
from ci_git_owner import git_auth_environment
|
||||
os.environ.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="fixture.inherited",
|
||||
GIT_CONFIG_VALUE_0="retained")
|
||||
inherited = dict(os.environ)
|
||||
auth = git_auth_environment(remote, token)
|
||||
assert dict(os.environ) == inherited, "auth helper mutated the policy environment"
|
||||
assert git_output(os.getcwd(), "config", "--get", "fixture.inherited", env=auth).strip() == "retained"
|
||||
if phase in ("fetch-only", "fetch"):
|
||||
run_git(os.getcwd(), "-c", "protocol.version=2", "fetch", "--filter=blob:none",
|
||||
"--depth=1", "origin", "refs/heads/main", env=auth, timeout=15)
|
||||
elif phase == "checkout":
|
||||
run_git(os.getcwd(), "checkout", "--detach", "FETCH_HEAD", env=auth, timeout=15)
|
||||
elif phase == "scope":
|
||||
run_git(os.getcwd(), "ls-remote", remote.replace("/repo.git", "/other.git"),
|
||||
env=auth, timeout=15)
|
||||
elif phase == "scope-host":
|
||||
run_git(os.getcwd(), "ls-remote", remote.replace("127.0.0.1", "localhost"),
|
||||
env=auth, timeout=15)
|
||||
elif phase == "redirect":
|
||||
run_git(os.getcwd(), "ls-remote", remote, env=auth, timeout=15)
|
||||
''')
|
||||
|
||||
def owned(phase, selected_policy=policy):
|
||||
with subprocess.Popen([sys.executable, "-I", "-S", owner, "--policy", str(selected_policy),
|
||||
remote, token, phase], cwd=workspace, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as child:
|
||||
try:
|
||||
stdout, stderr = child.communicate(timeout=25)
|
||||
except BaseException:
|
||||
# The Git owner must drain its separately owned Git groups
|
||||
# before this fixture closes the server or removes its root.
|
||||
child.terminate()
|
||||
try:
|
||||
child.communicate(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
raise RuntimeError("checkout owner did not finish cancellation cleanup")
|
||||
raise
|
||||
return subprocess.CompletedProcess(child.args, child.returncode, stdout, stderr)
|
||||
|
||||
if mode == "historical":
|
||||
env["GIT_CONFIG_GLOBAL"] = str(home / ".gitconfig")
|
||||
policy.write_text('''import ci_git_owner as owner
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
remote, token, phase = sys.argv[3:]
|
||||
owner.kind, owner.reset = "linux-node", True
|
||||
owner.workspace, owner.remote = os.getcwd(), remote
|
||||
owner.checkout_environment = owner.git_auth_environment(remote, token)
|
||||
try:
|
||||
owner.checkout()
|
||||
finally:
|
||||
owner.checkout_environment.clear()
|
||||
historical = json.loads(os.environ["CHECKOUT_GIT_COMMITS_JSON"])[0]
|
||||
owner.run_git(os.getcwd(), "cat-file", "-e", historical + "^{commit}")
|
||||
reader = str(Path.cwd().parent / "historical-reader")
|
||||
owner.run_git(os.getcwd(), "worktree", "add", "--detach", reader, historical)
|
||||
assert Path(reader, "payload.txt").read_text() == "checkout must hydrate this promised blob\\n"
|
||||
owner.run_git(os.getcwd(), "worktree", "remove", reader)
|
||||
''')
|
||||
env.update(CHECKOUT_SHA=revision, WORKFLOW_SHA=revision,
|
||||
CHECKOUT_GIT_COMMITS_JSON=json.dumps([historical_revision]))
|
||||
result = owned("historical")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert requests and all(item["authenticated"] for item in requests)
|
||||
config = (workspace / ".git/config").read_text()
|
||||
assert token not in config and encoded not in config and "extraheader" not in config.lower()
|
||||
assert checked(git, "rev-parse", "HEAD", cwd=workspace) == revision
|
||||
print(json.dumps({"historicalReaderPrepared": True, "credentialPersisted": False}))
|
||||
raise SystemExit(0)
|
||||
|
||||
fetched = owned("fetch-only" if mode == "fetch-only" else "fetch")
|
||||
assert fetched.returncode == 0, fetched.stderr
|
||||
assert requests and all(item["authenticated"] for item in requests)
|
||||
assert checked(git, "config", "remote.origin.promisor", cwd=workspace) == "true"
|
||||
missing = checked(git, "rev-list", "--objects", "--missing=print", "FETCH_HEAD", cwd=workspace)
|
||||
assert f"?{blob}" in missing, "initial filtered fetch unexpectedly downloaded the blob"
|
||||
fetch_requests = len(requests)
|
||||
if mode == "fetch-only":
|
||||
# Use the unmodified owner's actual checkout call: authentication on
|
||||
# the earlier fetch alone cannot reach the lazy promisor subprocess.
|
||||
policy.write_text('''from ci_git_owner import run_git
|
||||
import os
|
||||
run_git(os.getcwd(), "checkout", "--detach", "FETCH_HEAD", timeout=15)
|
||||
''')
|
||||
hydrated = owned("checkout")
|
||||
assert len(requests) > fetch_requests, "checkout did not exercise lazy network hydration"
|
||||
if mode == "fetch-only":
|
||||
assert hydrated.returncode != 0, "unauthenticated lazy checkout unexpectedly succeeded"
|
||||
assert any(not item["authenticated"] for item in requests[fetch_requests:])
|
||||
assert "could not fetch" in hydrated.stderr, hydrated.stderr
|
||||
else:
|
||||
assert hydrated.returncode == 0, hydrated.stderr
|
||||
assert all(item["authenticated"] for item in requests[fetch_requests:])
|
||||
assert (workspace / "payload.txt").read_text() == (source / "payload.txt").read_text()
|
||||
assert checked(git, "rev-parse", "HEAD", cwd=workspace) == revision
|
||||
scoped = owned("scope")
|
||||
assert scoped.returncode != 0
|
||||
assert requests[-1] == {"path": "/other.git/info/refs", "authenticated": False, "authorizationPresent": False}
|
||||
scoped_host = owned("scope-host")
|
||||
assert scoped_host.returncode != 0
|
||||
assert requests[-1] == {"path": "/repo.git/info/refs", "authenticated": False, "authorizationPresent": False}
|
||||
before_redirect = len(requests)
|
||||
redirect = True
|
||||
redirected = owned("redirect")
|
||||
assert redirected.returncode != 0
|
||||
assert requests[before_redirect:] == [{"path": "/repo.git/info/refs", "authenticated": True, "authorizationPresent": True}]
|
||||
if mode != "fetch-only":
|
||||
entry = root / "entry.py"
|
||||
entry.write_text('''import ci_git_owner as owner
|
||||
import base64, os, sys
|
||||
_, token, phase = sys.argv[3:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
expected_auth = phase not in ("cross-repository", "missing-token")
|
||||
os.environ.update(CHECKOUT_KIND="platform", GITHUB_WORKSPACE=os.getcwd(),
|
||||
CHECKOUT_REPO="fixture/repository", GITHUB_REPOSITORY="fixture/repository")
|
||||
if phase == "cross-repository":
|
||||
os.environ["GITHUB_REPOSITORY"] = "fixture/other"
|
||||
if phase != "missing-token":
|
||||
os.environ["CHECKOUT_TOKEN"] = token
|
||||
header = "AUTHORIZATION: basic " + base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||
def observed_header():
|
||||
try:
|
||||
return owner.git_output(os.getcwd(), "config", "--get-urlmatch", "http.extraheader", owner.remote).strip()
|
||||
except owner.GitFailure as error:
|
||||
assert error.code == 1
|
||||
return ""
|
||||
def checkout():
|
||||
assert observed_header() == (header if expected_auth else "")
|
||||
owner.run_git(os.getcwd(), "-c", 'alias.token-absent=!test "${CHECKOUT_TOKEN+x}" != x', "token-absent")
|
||||
if phase == "failure":
|
||||
raise owner.GitFailure(23)
|
||||
owner.checkout = checkout
|
||||
try:
|
||||
owner.main()
|
||||
except SystemExit as error:
|
||||
assert error.code == (23 if phase == "failure" else 0)
|
||||
else:
|
||||
raise AssertionError("main returned instead of reporting checkout outcome")
|
||||
assert not owner.checkout_environment, "checkout auth survived entry completion"
|
||||
assert observed_header() == "", "later generic Git inherited checkout authentication"
|
||||
print("main entry scope and cleanup passed")
|
||||
''')
|
||||
for phase in ("success", "failure", "cross-repository", "missing-token"):
|
||||
result = owned(phase, entry)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "main entry scope and cleanup passed"
|
||||
config = (workspace / ".git/config").read_text()
|
||||
assert token not in config and encoded not in config and "extraheader" not in config.lower()
|
||||
assert "followredirects" not in config.lower()
|
||||
for result in [fetched, hydrated]:
|
||||
assert token not in result.stdout + result.stderr
|
||||
assert encoded not in result.stdout + result.stderr
|
||||
print(json.dumps({"mode": mode, "fetchAuthenticated": True,
|
||||
"lazyCheckoutSucceeded": hydrated.returncode == 0,
|
||||
"missingBlobBeforeCheckout": True,
|
||||
"credentialPersisted": False, "requests": requests}))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
assert not thread.is_alive(), "Git fixture HTTP server survived cleanup"
|
||||
|
|
@ -301,7 +301,8 @@ if (args.includes('push')) {
|
|||
const result = spawnSync(git, args, { stdio: 'inherit' });
|
||||
if (mainFetch && result.status === 0) {
|
||||
const prefix = args.slice(0, args.indexOf('fetch'));
|
||||
const fetched = runGit([...prefix, 'rev-parse', 'FETCH_HEAD']);
|
||||
const destination = args.at(-1).split(':')[1] || 'FETCH_HEAD';
|
||||
const fetched = runGit([...prefix, 'rev-parse', destination]);
|
||||
if (control.moveSharedAfterFetch) {
|
||||
runGit(['-C', canonical, 'update-ref', 'refs/remotes/origin/main', movedMain]);
|
||||
}
|
||||
|
|
@ -313,7 +314,8 @@ if (mainFetch && result.status === 0) {
|
|||
event({
|
||||
kind: 'fetched',
|
||||
sha: fetched,
|
||||
shared: runGit(['-C', canonical, 'rev-parse', 'refs/remotes/origin/main']),
|
||||
shared: spawnSync(git, ['-C', canonical, 'rev-parse', '--verify', 'refs/remotes/origin/main'],
|
||||
{ encoding: 'utf8' }).stdout.trim(),
|
||||
});
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
|
|||
|
|
@ -842,6 +842,7 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
// that a previous successful main fetch left before this invocation.
|
||||
git("update-ref", "refs/remotes/origin/main", cachedMain);
|
||||
git("update-ref", "refs/heads/pr-42", cachedMain);
|
||||
const canonicalFetchHead = readFileSync(join(repoDir, ".git/FETCH_HEAD"), "utf8");
|
||||
expect(git("rev-parse", "refs/remotes/origin/main")).toBe(cachedMain);
|
||||
expect(git("ls-remote", "origin", "refs/pull/42/head")).toBe(
|
||||
`${pullHead}\trefs/pull/42/head`,
|
||||
|
|
@ -934,15 +935,22 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'original=("$@")',
|
||||
'if [ "${1-}" = -C ]; then shift 2; fi',
|
||||
'case "${1-}" in --git-dir=*) shift ;; esac',
|
||||
'prefix=("$OPENCLAW_TEST_REAL_GIT")',
|
||||
'if [ "${1-}" = -C ]; then prefix+=("$1" "$2"); shift 2; fi',
|
||||
'case "${1-}" in --git-dir=*) prefix+=("$1"); shift ;; esac',
|
||||
'if [ "${1-}" = fetch ]; then',
|
||||
' target="${3-}"; [[ " $* " != *" +refs/heads/main:refs/remotes/origin/main "* ]] || target=main',
|
||||
' refspec=""; for arg in "$@"; do case "$arg" in -*) ;; *) refspec="$arg" ;; esac; done',
|
||||
' target="$refspec"; case "$refspec" in refs/heads/main|+refs/heads/main:*) target=main ;; esac',
|
||||
' result=0; "$OPENCLAW_TEST_REAL_GIT" "${original[@]}" || result=$?',
|
||||
' printf "fetch:%s:%s\\n" "$target" "$result" >> "$OPENCLAW_TEST_EVENTS"',
|
||||
' if [ "$target" = main ] && [ "$result" -eq 0 ] && [ "$OPENCLAW_TEST_FAILURE" = second ] && [ ! -e "$OPENCLAW_TEST_FIRST_MAIN" ]; then',
|
||||
' "$OPENCLAW_TEST_REAL_GIT" -C "$OPENCLAW_TEST_REPO" rev-parse refs/remotes/origin/main > "$OPENCLAW_TEST_FIRST_MAIN"',
|
||||
' "$OPENCLAW_TEST_REAL_GIT" --git-dir="$OPENCLAW_TEST_ORIGIN" update-ref -d refs/heads/main',
|
||||
' if [ "$target" = main ] && [ "$result" -eq 0 ]; then',
|
||||
' destination=FETCH_HEAD; case "$refspec" in *:*) destination="${refspec#*:}" ;; esac',
|
||||
' fetched=$("${prefix[@]}" rev-parse "$destination")',
|
||||
' if [ "$destination" = FETCH_HEAD ]; then printf "checkpoint:%s\\n" "$fetched" >> "$OPENCLAW_TEST_EVENTS"; fi',
|
||||
' if [ "$OPENCLAW_TEST_FAILURE" = second ] && [ ! -e "$OPENCLAW_TEST_FIRST_MAIN" ]; then',
|
||||
' printf "%s\\n" "$fetched" > "$OPENCLAW_TEST_FIRST_MAIN"',
|
||||
' "$OPENCLAW_TEST_REAL_GIT" --git-dir="$OPENCLAW_TEST_ORIGIN" update-ref -d refs/heads/main',
|
||||
" fi",
|
||||
" fi",
|
||||
' exit "$result"',
|
||||
"fi",
|
||||
|
|
@ -961,7 +969,6 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
OPENCLAW_TEST_PR_METADATA: metadataPath,
|
||||
OPENCLAW_TEST_GH_EVENTS: ghEventsPath,
|
||||
OPENCLAW_TEST_REAL_GIT: realGit,
|
||||
OPENCLAW_TEST_REPO: repoDir,
|
||||
OPENCLAW_TEST_ORIGIN: originDir,
|
||||
OPENCLAW_TEST_EVENTS: eventsPath,
|
||||
OPENCLAW_TEST_FIRST_MAIN: firstMainPath,
|
||||
|
|
@ -993,6 +1000,16 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
expect.soft(git("branch", "--show-current")).toBe("main");
|
||||
expect.soft(git("write-tree")).toBe(canonicalTree);
|
||||
expect.soft(git("diff", "--exit-code")).toBe("");
|
||||
expect.soft(git("rev-parse", "refs/remotes/origin/main")).toBe(cachedMain);
|
||||
expect
|
||||
.soft(readFileSync(join(repoDir, ".git/FETCH_HEAD"), "utf8"))
|
||||
.toBe(canonicalFetchHead);
|
||||
expect
|
||||
.soft(
|
||||
events.filter((event) => event.startsWith("checkpoint:")),
|
||||
output,
|
||||
)
|
||||
.toEqual(failure === "healthy" ? [`checkpoint:${remoteMain}`] : []);
|
||||
if (failure === "auth" && (command === "review-init" || command === "review-claim")) {
|
||||
// The exact GH trace distinguishes the intended auth failure from an
|
||||
// unexpected fixture command that the auth diagnostic would also hide.
|
||||
|
|
@ -1007,7 +1024,6 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
expect.soft(controller.exitCode, output).toBe(1);
|
||||
expect.soft(output).toContain("GitHub CLI auth is not usable");
|
||||
expect.soft(events.filter(Boolean), output).toEqual([]);
|
||||
expect.soft(git("rev-parse", "refs/remotes/origin/main")).toBe(cachedMain);
|
||||
expect.soft(git("rev-parse", "refs/heads/pr-42")).toBe(cachedMain);
|
||||
expect.soft(existsSync(worktreeDir)).toBe(existing);
|
||||
if (existing) {
|
||||
|
|
@ -1070,7 +1086,6 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
}
|
||||
// Nested validation shares the same captured main snapshot.
|
||||
expect.soft(fetches, output).toEqual(["fetch:main:0"]);
|
||||
expect.soft(git("rev-parse", "refs/remotes/origin/main")).toBe(remoteMain);
|
||||
expect.soft(output).toContain("review guard passed");
|
||||
if (command === "review-tests") {
|
||||
expect.soft(controller.exitCode, output).toBe(1);
|
||||
|
|
@ -1113,9 +1128,6 @@ describePosix("scripts/pr per-PR operation lock", () => {
|
|||
expect.soft(output).toContain("couldn't find remote ref refs/heads/main");
|
||||
expect.soft(output).not.toContain("wrote=.local/pr-meta.json");
|
||||
expect.soft(git("rev-parse", "refs/heads/pr-42")).toBe(cachedMain);
|
||||
expect
|
||||
.soft(git("rev-parse", "refs/remotes/origin/main"))
|
||||
.toBe(failure === "first" ? cachedMain : remoteMain);
|
||||
if (failure === "second") {
|
||||
expect.soft(readFileSync(firstMainPath, "utf8").trim()).toBe(remoteMain);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
|
|
@ -205,6 +205,87 @@ function reviewState(worktree: string) {
|
|||
}
|
||||
|
||||
describePosix("scripts/pr worktree containment", () => {
|
||||
it("refreshes warm and cold PRs concurrently while another Git transaction owns shared main", async () => {
|
||||
const fixture = createFixture();
|
||||
git(fixture.root, "worktree", "add", "--detach", ".worktrees/pr-42", fixture.mainSha);
|
||||
git(fixture.root, "update-ref", "refs/heads/main", fixture.siblingSha);
|
||||
const fetchHead = join(fixture.root, ".git", "FETCH_HEAD");
|
||||
const previousFetch = readFileSync(fetchHead, "utf8");
|
||||
const writer = spawn("git", ["update-ref", "--stdin"], { cwd: fixture.root });
|
||||
let writerOutput = "";
|
||||
writer.stderr.on("data", (chunk) => {
|
||||
writerOutput += chunk;
|
||||
});
|
||||
const closed = new Promise<number | null>((resolve, reject) => {
|
||||
writer.once("error", reject);
|
||||
writer.once("close", resolve);
|
||||
});
|
||||
const prepared = new Promise<void>((resolve) => {
|
||||
writer.stdout.on("data", (chunk) => {
|
||||
writerOutput += chunk;
|
||||
if (writerOutput.includes("prepare: ok\n")) resolve();
|
||||
});
|
||||
});
|
||||
writer.stdin.write(
|
||||
`start\nupdate refs/remotes/origin/main ${fixture.siblingSha} ${fixture.mainSha}\nprepare\n`,
|
||||
);
|
||||
try {
|
||||
await Promise.race([
|
||||
prepared,
|
||||
closed.then((code) => {
|
||||
throw new Error(
|
||||
`Shared ref transaction exited before preparation: ${code}\n${writerOutput}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const results = await Promise.all(
|
||||
[42, 43].map(
|
||||
(pr) =>
|
||||
new Promise<{ code: number | null; output: string }>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
'set -euo pipefail\nsource "$1"\nsource "$2"\nsource "$3"\nscript_parent_dir="$4"\ngh_plain() { :; }\nmark_pr_operation_side_effects_started() { :; }\nreview_checkout_main "$5"',
|
||||
"pr-concurrency",
|
||||
commonScript,
|
||||
worktreeScript,
|
||||
reviewScript,
|
||||
fixture.root,
|
||||
String(pr),
|
||||
],
|
||||
{ cwd: fixture.root, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
resolve({ code, output });
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const result of results) expect.soft(result.code, result.output).toBe(0);
|
||||
expect(writer.exitCode).toBeNull();
|
||||
expect(readFileSync(fetchHead, "utf8")).toBe(previousFetch);
|
||||
expect(git(fixture.root, "rev-parse", "refs/remotes/origin/main")).toBe(fixture.mainSha);
|
||||
for (const pr of [42, 43]) {
|
||||
const worktree = join(fixture.root, ".worktrees", `pr-${pr}`);
|
||||
expect(git(worktree, "rev-parse", "HEAD")).toBe(fixture.siblingSha);
|
||||
expect(git(worktree, "status", "--porcelain", "--untracked-files=no")).toBe("");
|
||||
}
|
||||
expectCanonicalCheckoutUnchanged(fixture);
|
||||
} finally {
|
||||
writer.stdin.end("abort\n");
|
||||
await closed;
|
||||
}
|
||||
});
|
||||
|
||||
for (const caller of ["enter_worktree 42 true || exit $?", "review_init 42"] as const) {
|
||||
it.each([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||
"test/scripts/changed-lanes.test.ts",
|
||||
"test/scripts/install-trufflehog.test.ts",
|
||||
"test/scripts/pr-prepare-gates.test.ts",
|
||||
"test/scripts/testbox-base.test.ts",
|
||||
"test/scripts/testbox-lease-freshness.test.ts",
|
||||
],
|
||||
],
|
||||
|
|
@ -1057,6 +1058,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||
"test/scripts/ci-workflow-guards.test.ts",
|
||||
"test/scripts/package-acceptance-workflow.test.ts",
|
||||
"test/scripts/install-trufflehog.test.ts",
|
||||
"test/scripts/testbox-base.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
|
|
@ -1065,6 +1067,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||
"test/scripts/install-trufflehog.test.ts",
|
||||
"test/scripts/package-acceptance-workflow.test.ts",
|
||||
"test/scripts/ci-workflow-guards.test.ts",
|
||||
"test/scripts/testbox-base.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
|
|
@ -2058,6 +2061,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||
forwardedArgs: [],
|
||||
includePatterns: [
|
||||
"test/scripts/android-version.test.ts",
|
||||
"test/scripts/ci-git-prerequisites.test.ts",
|
||||
"test/scripts/ios-release-plan.test.ts",
|
||||
],
|
||||
watchMode: false,
|
||||
|
|
|
|||
189
test/scripts/testbox-base.test.ts
Normal file
189
test/scripts/testbox-base.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { devNull } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
const workflows = [
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
".github/workflows/ci-check-arm-testbox.yml",
|
||||
".github/workflows/ci-build-artifacts-testbox.yml",
|
||||
];
|
||||
|
||||
type Step = {
|
||||
name: string;
|
||||
id?: string;
|
||||
run?: string;
|
||||
uses?: string;
|
||||
env?: Record<string, string>;
|
||||
with?: Record<string, string>;
|
||||
};
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function runBasePreparation(repo: string, workflowName: string, base: string, trace: string) {
|
||||
const workflow = parse(fs.readFileSync(workflowName, "utf8"));
|
||||
const job = Object.values(workflow.jobs)[0] as { steps: Step[] };
|
||||
const values = new Map([
|
||||
["github.event.pull_request.base.sha", base],
|
||||
["github.event.pull_request.base.ref", "main"],
|
||||
]);
|
||||
const interpolate = (value: string): string =>
|
||||
value.replace(/\$\{\{\s*(.*?)\s*\}\}/gu, (_match, expression: string) => {
|
||||
// These workflows use only a PR-output-or-dispatch-default expression here.
|
||||
const [key = ""] = expression
|
||||
.replace(/^github.event_name == 'pull_request' && /u, "")
|
||||
.split(" || ");
|
||||
const resolved = values.get(key);
|
||||
if (resolved === undefined) {
|
||||
throw new Error(`Unbound workflow expression: ${expression}`);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
const renderEnv = (env: Record<string, string> = {}) =>
|
||||
Object.fromEntries(Object.entries(env).map(([key, value]) => [key, interpolate(value)]));
|
||||
const bin = path.join(createTempDir("openclaw-testbox-tools-"), "bin");
|
||||
fs.mkdirSync(bin);
|
||||
// System-wide Node links are unrelated to Git preparation and must stay fixture-local.
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "sudo"),
|
||||
'#!/bin/sh\nif [ "$1" = tee ]; then cat >/dev/null; fi\n',
|
||||
);
|
||||
fs.chmodSync(path.join(bin, "sudo"), 0o755);
|
||||
const output = path.join(repo, "step-output.txt");
|
||||
const first = job.steps.findIndex((step) => step.name === "Ensure Testbox base commit");
|
||||
expect(first).toBeGreaterThan(0);
|
||||
const previous = job.steps[first - 1];
|
||||
const steps = [
|
||||
...(previous?.id === "testbox-base" ? [previous] : []),
|
||||
...job.steps.slice(first, first + 2),
|
||||
];
|
||||
let result: ReturnType<typeof spawnSync> | undefined;
|
||||
for (const step of steps) {
|
||||
let command = step.run;
|
||||
let commandEnv = renderEnv(step.env);
|
||||
if (step.uses) {
|
||||
const actionPath = path.join(repo, step.uses);
|
||||
values.set("github.action_path", actionPath);
|
||||
for (const [key, value] of Object.entries(step.with ?? {})) {
|
||||
values.set(`inputs.${key}`, interpolate(value));
|
||||
}
|
||||
const action = parse(fs.readFileSync(path.join(actionPath, "action.yml"), "utf8"));
|
||||
const actionStep: Step = action.runs.steps[0];
|
||||
command = actionStep.run;
|
||||
commandEnv = { ...commandEnv, ...renderEnv(actionStep.env) };
|
||||
}
|
||||
if (!command) {
|
||||
throw new Error(`Workflow step has no executable command: ${step.name}`);
|
||||
}
|
||||
fs.writeFileSync(output, "");
|
||||
result = spawnSync("bash", ["-euo", "pipefail", "-c", interpolate(command)], {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
...commandEnv,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
RUNNER_OS: process.platform === "win32" ? "Windows" : "Linux",
|
||||
GITHUB_OUTPUT: output,
|
||||
GIT_CONFIG_GLOBAL: devNull,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_ALLOW_PROTOCOL: "",
|
||||
GIT_TRACE2_EVENT: trace,
|
||||
},
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return result;
|
||||
}
|
||||
if (step.id) {
|
||||
for (const line of fs.readFileSync(output, "utf8").trim().split("\n")) {
|
||||
const separator = line.indexOf("=");
|
||||
values.set(
|
||||
`steps.${step.id}.outputs.${line.slice(0, separator)}`,
|
||||
line.slice(separator + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe.each(workflows)("%s Testbox base preparation", (workflowName) => {
|
||||
it.each([
|
||||
{ shape: "merge", branch: "main", depth: 2, passes: true },
|
||||
{ shape: "linear", branch: "feature", depth: 2, passes: true },
|
||||
{ shape: "merge without parents", branch: "main", depth: 1, passes: false },
|
||||
])("pins the correct base in a shallow $shape checkout", ({ branch, depth, passes }) => {
|
||||
const source = createTempDir("openclaw-testbox-source-");
|
||||
git(source, "init", "-q", "--initial-branch=main");
|
||||
git(source, "config", "user.name", "Test User");
|
||||
git(source, "config", "user.email", "test@example.com");
|
||||
for (const action of ["git-owner", "ensure-base-commit", "prepare-testbox-shell"]) {
|
||||
fs.cpSync(`.github/actions/${action}`, path.join(source, ".github/actions", action), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(path.join(source, "scripts/lib"), { recursive: true });
|
||||
for (const helper of ["merge-head-diff-base.mjs", "arg-utils.runtime.mjs"]) {
|
||||
fs.copyFileSync(`scripts/lib/${helper}`, path.join(source, "scripts/lib", helper));
|
||||
}
|
||||
git(source, "add", ".");
|
||||
git(source, "commit", "-qm", "base");
|
||||
const eventBase = git(source, "rev-parse", "HEAD");
|
||||
git(source, "switch", "-q", "-c", "feature");
|
||||
fs.writeFileSync(path.join(source, "feature.txt"), "feature\n");
|
||||
git(source, "add", ".");
|
||||
git(source, "commit", "-qm", "feature");
|
||||
git(source, "switch", "-q", "main");
|
||||
fs.writeFileSync(path.join(source, "main.txt"), "main\n");
|
||||
git(source, "add", ".");
|
||||
git(source, "commit", "-qm", "main advanced");
|
||||
const mainBase = git(source, "rev-parse", "HEAD");
|
||||
git(source, "merge", "--no-ff", "feature", "-m", "synthetic merge");
|
||||
const repo = createTempDir("openclaw-testbox-shallow-");
|
||||
git(
|
||||
source,
|
||||
"clone",
|
||||
"--quiet",
|
||||
"--no-local",
|
||||
`--depth=${depth}`,
|
||||
"--branch",
|
||||
branch,
|
||||
source,
|
||||
repo,
|
||||
);
|
||||
expect(git(repo, "rev-parse", "--is-shallow-repository")).toBe("true");
|
||||
expect(
|
||||
spawnSync("git", ["cat-file", "-e", `${eventBase}^{commit}`], { cwd: repo }).status === 0,
|
||||
).toBe(branch === "feature");
|
||||
const before = spawnSync("git", ["rev-parse", "refs/remotes/origin/main"], {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const trace = path.join(createTempDir("openclaw-testbox-trace-"), "git.jsonl");
|
||||
const result = runBasePreparation(repo, workflowName, eventBase, trace);
|
||||
const fetches = fs
|
||||
.readFileSync(trace, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter((event) => event.event === "cmd_name" && event.name === "fetch");
|
||||
if (!passes) {
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stdout).toContain("Base commit still unavailable");
|
||||
expect(fetches).toHaveLength(5);
|
||||
expect(git(repo, "rev-parse", "refs/remotes/origin/main")).toBe(before.stdout.trim());
|
||||
return;
|
||||
}
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(fetches).toEqual([]);
|
||||
expect(git(repo, "rev-parse", "refs/remotes/origin/main")).toBe(
|
||||
branch === "main" ? mainBase : eventBase,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,11 @@ suite.define(() => {
|
|||
await trigger.waitFor({ state: "visible" });
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
// Opening initializes the selected row after its animation completes.
|
||||
const selected = picker.getByRole("menuitemradio", { name: "main", exact: true });
|
||||
await expect
|
||||
.poll(() => selected.evaluate((element) => document.activeElement === element))
|
||||
.toBe(true);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const option = picker.getByRole("menuitemradio", { name: "research", exact: true });
|
||||
await expect
|
||||
|
|
@ -50,6 +55,8 @@ suite.define(() => {
|
|||
.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
|
||||
)
|
||||
.toBe(false);
|
||||
// The open property clears before the closing popup retires.
|
||||
await picker.locator('wa-dropdown-item[aria-label="research"]').waitFor({ state: "hidden" });
|
||||
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue