mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
merge origin/main into local main
Integrates origin's worker-JWT-auth work (commits 8a6f83bb0..c847a828d) with the config-boundary refactor that landed locally. Conflicts resolved: - commands/dump.rs: take origin's removal of the 500-line in-process test block (replaced by real-server integration coverage). - commands/run/runner.rs: keep local's dense WorkflowSettings import, drop dead SettingsLayer import, pull in origin's ActorRef. - manifest_builder.rs: adopt origin's lifted working_directory resolution (fixes #159 - manifest git detection in nested repos), but via local's resolve_working_directory_from_run API that takes the dense RunNamespace. Update the regression test's ManifestBuildInput literal to local's run_overrides/cli_overrides field shape. - server.rs: keep origin's jwt_auth_mode/jwt_auth_state/ test_user_subject/issue_test_user_jwt/issue_test_worker_token/ create_run_with_bearer/bearer_request test helpers, adapt jwt_auth_state to local's create_test_app_state_with_session_key signature (ServerSettings + RunLayer), keep local's dense canonical_origin_settings that returns ServerSettings via server_settings_from_toml. Rewrite build_app_state_requires_session_secret_for_worker_tokens against the dense AppStateConfig (resolved_settings + resolved_runtime_settings_for_tests). Post-merge verification: workspace builds clean, cargo +nightly fmt --check all clean, cargo +nightly clippy --workspace --all-targets -- -D warnings clean, cargo nextest run --workspace 4560 tests passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
099dd881a8
48 changed files with 3614 additions and 1749 deletions
1
.github/workflows/rust.yml
vendored
1
.github/workflows/rust.yml
vendored
|
|
@ -47,7 +47,6 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
- run: bin/dev/check-boundary.sh
|
||||
- run: bin/dev/check-env-mutation.sh
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
|
|
|
|||
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -1706,6 +1706,7 @@ dependencies = [
|
|||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -4461,9 +4462,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
|
|
@ -4502,9 +4503,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@astrojs/react": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"astro": "^5.9.3",
|
||||
"astro": "^6.1.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.1"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ cd "$(dirname "$0")/../.."
|
|||
|
||||
server_symbol_allowlist=(
|
||||
"lib/crates/fabro-cli/src/local_server.rs"
|
||||
"lib/crates/fabro-cli/src/commands/install.rs"
|
||||
"lib/crates/fabro-cli/src/commands/run/runner.rs"
|
||||
"lib/crates/fabro-cli/src/commands/pr/mod.rs"
|
||||
"lib/crates/fabro-cli/src/commands/pr/create.rs"
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
matches=$(rg -n 'std::env::(set_var|remove_var)' --glob '*.rs' || true)
|
||||
else
|
||||
matches=$(grep -R -n -E 'std::env::(set_var|remove_var)' . --include='*.rs' --exclude-dir=target --exclude-dir=.git || true)
|
||||
fi
|
||||
|
||||
fail=0
|
||||
while IFS= read -r match; do
|
||||
[[ -z "$match" ]] && continue
|
||||
|
||||
path=${match%%:*}
|
||||
rest=${match#*:}
|
||||
line=${rest#*:}
|
||||
line=${line#"${line%%[![:space:]]*}"}
|
||||
|
||||
case "$path:$line" in
|
||||
"lib/crates/fabro-telemetry/src/spawn.rs:std::env::set_var(key, value);" | \
|
||||
"lib/crates/fabro-telemetry/src/spawn.rs:std::env::remove_var(key);" | \
|
||||
'lib/crates/fabro-server/src/install.rs:std::env::set_var("FABRO_TEST_IN_MEMORY_STORE", "1");')
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "process env mutation check failed: $match" >&2
|
||||
fail=1
|
||||
done <<< "$matches"
|
||||
|
||||
if [[ $fail -ne 0 ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
Do not mutate process-wide env with std::env::set_var/remove_var.
|
||||
Inject env at construction time or on child-process Command values instead.
|
||||
See docs-internal/server-secrets-strategy.md.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Process env mutation checks passed."
|
||||
220
bun.lock
220
bun.lock
|
|
@ -33,10 +33,10 @@
|
|||
"apps/marketing": {
|
||||
"name": "marketing",
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@astrojs/react": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"astro": "^5.9.3",
|
||||
"astro": "^6.1.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
|
@ -70,17 +70,17 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
|
||||
"@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="],
|
||||
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="],
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.9.0", "", { "dependencies": { "picomatch": "^4.0.4" } }, "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg=="],
|
||||
|
||||
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.10", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.5", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.19.0", "smol-toml": "^1.5.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A=="],
|
||||
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.9.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA=="],
|
||||
|
||||
"@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
"@astrojs/prism": ["@astrojs/prism@4.0.1", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ=="],
|
||||
|
||||
"@astrojs/react": ["@astrojs/react@4.4.2", "", { "dependencies": { "@vitejs/plugin-react": "^4.7.0", "ultrahtml": "^1.6.0", "vite": "^6.4.1" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ=="],
|
||||
"@astrojs/react": ["@astrojs/react@5.0.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.9.0", "@vitejs/plugin-react": "^5.2.0", "devalue": "^5.6.4", "ultrahtml": "^1.6.0", "vite": "^7.3.2" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-yDNE4VnKOzCjH9dCBi7pT4F6kpI3M9TkS+uxnCB0sGIS6t5vKonOY+Hs/UUnSajJGT5jeBRfpI9IQp+r/n1fBA=="],
|
||||
|
||||
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="],
|
||||
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.1", "", { "dependencies": { "ci-info": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^4.0.0", "is-wsl": "^3.1.1", "which-pm-runs": "^1.1.0" } }, "sha512-7fcIxXS9J4ls5tr8b3ww9rbAIz2+HrhNJYZdkAhhB4za/I5IZ/60g+Bs8q7zwG0tOIZfNB4JWhVJ1Qkl/OrNCw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
|
|
@ -122,6 +122,10 @@
|
|||
|
||||
"@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
|
@ -374,7 +378,7 @@
|
|||
|
||||
"@remotion/zod-types": ["@remotion/zod-types@4.0.437", "", { "dependencies": { "remotion": "4.0.437" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-u/OR5khjFWXMBx+UaZjtvltqEOHDcdj5EFMHI3EuufwB2vVsYs8HCMWSRKElE+2UV6CjK+CtgrE+eVZv1ohuQQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
|
|
@ -464,6 +468,8 @@
|
|||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
|
||||
|
|
@ -556,7 +562,7 @@
|
|||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="],
|
||||
|
||||
|
|
@ -604,12 +610,6 @@
|
|||
|
||||
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
|
@ -620,7 +620,7 @@
|
|||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"astro": ["astro@5.18.0", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", "@astrojs/markdown-remark": "6.3.10", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ=="],
|
||||
"astro": ["astro@6.1.9", "", { "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.9.0", "@astrojs/markdown-remark": "7.1.1", "@astrojs/telemetry": "3.3.1", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.5", "vfile": "^6.0.3", "vite": "^7.3.2", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-NsAHzMzpznB281g2aM5qnBt2QjfH6ttKiZ3hSZw52If8JJ+62kbnBKbyKhR2glQcJLl7Jfe4GSl0DihFZ36rRQ=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
|
|
@ -630,16 +630,12 @@
|
|||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
|
@ -648,14 +644,10 @@
|
|||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001775", "", {}, "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
|
@ -668,8 +660,6 @@
|
|||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
|
@ -678,13 +668,13 @@
|
|||
|
||||
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
"common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||
"cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
|
|
@ -710,7 +700,7 @@
|
|||
|
||||
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
|
|
@ -720,9 +710,7 @@
|
|||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="],
|
||||
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
|
|
@ -746,8 +734,6 @@
|
|||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
|
@ -762,7 +748,7 @@
|
|||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
|
|
@ -782,7 +768,7 @@
|
|||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
|
|
@ -802,8 +788,14 @@
|
|||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
|
@ -826,8 +818,6 @@
|
|||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
|
@ -842,7 +832,7 @@
|
|||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="],
|
||||
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
|
|
@ -884,16 +874,12 @@
|
|||
|
||||
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
|
||||
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
|
@ -956,7 +942,7 @@
|
|||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
|
||||
|
||||
"lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
|
||||
|
||||
|
|
@ -1098,6 +1084,8 @@
|
|||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
|
@ -1112,11 +1100,11 @@
|
|||
|
||||
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
||||
"p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="],
|
||||
|
||||
"p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="],
|
||||
"p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="],
|
||||
|
||||
"p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
"p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
|
|
@ -1132,7 +1120,7 @@
|
|||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
|
|
@ -1252,12 +1240,8 @@
|
|||
|
||||
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
|
||||
|
|
@ -1280,7 +1264,9 @@
|
|||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
"tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
|
|
@ -1294,8 +1280,6 @@
|
|||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
|
@ -1328,7 +1312,7 @@
|
|||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="],
|
||||
"unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
|
|
@ -1344,7 +1328,7 @@
|
|||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||
|
||||
|
|
@ -1364,10 +1348,6 @@
|
|||
|
||||
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
|
||||
|
||||
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
|
|
@ -1376,24 +1356,18 @@
|
|||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
"@babel/core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
@ -1408,19 +1382,19 @@
|
|||
|
||||
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@parcel/watcher/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@remotion/bundler/esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="],
|
||||
|
||||
"@remotion/renderer/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
||||
|
||||
"@remotion/studio/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
|
||||
|
||||
"@remotion/studio/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@remotion/studio-server/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
|
||||
|
||||
"@remotion/zod-types/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"@rollup/pluginutils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
|
|
@ -1444,14 +1418,12 @@
|
|||
|
||||
"@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
|
@ -1460,9 +1432,9 @@
|
|||
|
||||
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
|
||||
|
||||
"magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"marketing/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
"magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
|
|
@ -1480,12 +1452,22 @@
|
|||
|
||||
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@remotion/bundler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="],
|
||||
|
|
@ -1570,9 +1552,17 @@
|
|||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
"astro/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
|
||||
|
||||
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
|
||||
|
||||
"astro/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
|
||||
|
||||
"astro/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
|
||||
|
||||
"astro/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
||||
|
|
@ -1580,58 +1570,6 @@
|
|||
|
||||
"terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
|
@ -1670,8 +1608,6 @@
|
|||
|
||||
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ This document defines how Fabro handles server-level secrets.
|
|||
- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
|
||||
- `process env` wins over `server.env` on conflicts.
|
||||
- `fabro server start` never generates secrets. Missing required secrets are a startup error.
|
||||
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. CI enforces this with `bin/dev/check-env-mutation.sh` so broad clippy suppressions cannot bypass it.
|
||||
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. Enforced by clippy via `disallowed_methods` in `clippy.toml`; intentional exceptions must be annotated with a scoped `#[expect(clippy::disallowed_methods, reason = "...")]` at the call site.
|
||||
|
||||
## Active Server Secrets
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ These values belong to the server runtime and are read via `state.server_secret(
|
|||
| Secret | Used by |
|
||||
|---|---|
|
||||
| `SESSION_SECRET` | Cookie encryption and JWT signing derivation |
|
||||
| `FABRO_DEV_TOKEN` | Dev-token auth for worker/server interactions |
|
||||
| `FABRO_DEV_TOKEN` | Dev-token user auth when `server.auth.methods` includes `dev-token` |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | GitHub App credentials |
|
||||
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification |
|
||||
| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth login |
|
||||
|
|
@ -46,7 +46,8 @@ There is no compatibility layer for removed secrets and no startup-time secret g
|
|||
## Subprocess Boundaries
|
||||
|
||||
- Worker and render-graph subprocesses start from `env_clear()` and re-add only explicit allowlisted variables.
|
||||
- Authority-bearing values are re-injected intentionally.
|
||||
- Authority-bearing values are re-injected intentionally. For worker subprocesses this is `FABRO_WORKER_TOKEN`, not user auth state such as `FABRO_DEV_TOKEN` or `auth.json`.
|
||||
- The worker reads `FABRO_WORKER_TOKEN` from its env at startup (in `main()` before Tokio initializes) and immediately calls `std::env::remove_var` to scrub it. The token then flows through function arguments to `runner::execute`. Every descendant process (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it.
|
||||
- The daemon child inherits the parent env unchanged except for output-format hygiene (`FABRO_JSON` removal).
|
||||
|
||||
## Tests
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@
|
|||
"primary": {
|
||||
"type": "button",
|
||||
"label": "Get Started",
|
||||
"href": "https://fabro.dev/getting-started/quick-start"
|
||||
"href": "/getting-started/introduction"
|
||||
}
|
||||
},
|
||||
"contextual": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "refactor: Server-issued per-run JWT for worker subprocess auth"
|
||||
type: refactor
|
||||
status: active
|
||||
status: completed
|
||||
date: 2026-04-22
|
||||
deepened: 2026-04-22
|
||||
---
|
||||
|
|
@ -10,7 +10,7 @@ deepened: 2026-04-22
|
|||
|
||||
## Overview
|
||||
|
||||
Server mints one per-run JWT (HS256, 72h, claims include `run_id`) at every worker subprocess spawn, passes it to the worker via the `FABRO_WORKER_TOKEN` env var, and every run-scoped route accepts it. Worker stops reading `~/.fabro/auth.json`. Existing artifact-upload-token mechanism is folded into the new worker token (one credential covers all run-scoped routes). End-user auth (dev-token / github) is now strictly orthogonal to worker auth.
|
||||
Server mints one per-run JWT (HS256, 72h, claims include `run_id`) at every worker subprocess spawn, injects it into the worker env as `FABRO_WORKER_TOKEN`, and worker-touched run-scoped routes accept it. Worker stops reading `~/.fabro/auth.json`. The artifact-upload-token mechanism is deleted entirely (greenfield — no external consumers, no shim required). End-user auth (dev-token / github) is now strictly orthogonal to worker auth.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
|
|
@ -20,9 +20,9 @@ Workers POST back to the server (events, state, blobs, stage artifacts). Today t
|
|||
- (b) any deployment where the worker doesn't share a home dir with an authenticated CLI user (containerized server, `fabro` system user, remote worker, multi-tenant) silently fails;
|
||||
- (c) worker auth is implicitly coupled to end-user auth strategy when conceptually independent.
|
||||
|
||||
Today's GitHub-only install (`auth.methods = ["github"]`) writes no `FABRO_DEV_TOKEN` and `worker_command` (`server.rs:3836-3845`) injects nothing — the worker has no documented credential at all. Only the home-dir steal makes it work.
|
||||
GitHub-only install (`auth.methods = ["github"]`) writes no `FABRO_DEV_TOKEN` and `worker_command` (`server.rs:3740-3750`) injects nothing — the worker has no documented credential at all. Only the home-dir steal makes it work.
|
||||
|
||||
The artifact-upload-token mechanism (`server.rs:758`, `server.rs:861`) already proves the right pattern for one route. Generalize it.
|
||||
The artifact-upload-token mechanism (`server.rs:752`, `server.rs:846`) already proves the right pattern for one route. Generalize it to every worker-touched route.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
|
|
@ -32,90 +32,94 @@ The artifact-upload-token mechanism (`server.rs:758`, `server.rs:861`) already p
|
|||
- R4. Both `start` and `resume` spawn paths re-mint a fresh 72h credential.
|
||||
- R5. Worker auth works in any deployment topology, including GitHub-only installs with no `~/.fabro/auth.json` on the worker host.
|
||||
- R6. Worker-emitted events stamped as a system principal (`system:worker`); originator user identity remains discoverable on the run record.
|
||||
- R7. Run-scoped routes still accept end-user JWTs for non-worker callers (CLI, web UI) — fall-through, not replacement.
|
||||
- R7. Worker-touched run-scoped routes still accept end-user JWTs for non-worker callers (CLI, web UI) — fall-through, not replacement.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **Greenfield context:** no shipped deployments; no need to preserve in-flight workers across the change. Atomic swap. No backwards-compat shim. No deprecation cycle.
|
||||
- Out: refresh tokens for workers (decided: hard 72h ceiling per spawn, fresh mint on resume).
|
||||
- Out: in-memory or persisted revocation list. Per-run binding + 72h `exp` is the entire blast-radius bound.
|
||||
- Out: multi-host / remote worker spawning (this plan makes it *possible*, doesn't deliver it).
|
||||
- Out: changes to end-user auth methods (`ServerAuthMethod::DevToken | Github`).
|
||||
- Out: any new `RunAuthMethod` variant — worker token bypasses `AuthenticatedSubject` entirely.
|
||||
- Out: extending `RunSummary` with provenance — originator already on `RunSpec.provenance.subject` and that's enough.
|
||||
- Out: SSE attach routes (`/runs/{id}/attach`, `/attach`). These are end-user read paths (web UI / CLI tail). The worker is an event *producer*, not consumer; it never calls them. They keep `AuthenticatedService` (user-JWT-only).
|
||||
- Out: lifecycle endpoints (`/runs/{id}/cancel`, `/pause`, `/unpause`, `/archive`, `/unarchive`, `DELETE /runs/{id}`). The worker has no business invoking these — they remain user-JWT-only and the worker token is explicitly rejected on them (Unit 3).
|
||||
- Out: any `Display` impl on `Credential::Worker` payload. Plan keeps redacted `Debug` only; do not add `Display`. Token must never be `format!`-able as a side effect.
|
||||
- Out: SSE attach routes (`/runs/{id}/attach`, `/attach`). Worker is a producer, not a consumer; never calls them. Stay user-JWT-only.
|
||||
- Out: lifecycle/admin/user-action routes (`/runs/{id}/cancel`, `/pause`, `/unpause`, `/archive`, `/unarchive`, `DELETE /runs/{id}`, `submit_answer`, `start_run`, `create_run`, list endpoints). Worker token explicitly rejected on these — they remain user-JWT-only.
|
||||
- Out: any `Display` impl on `Credential::Worker` payload. Plan keeps redacted `Debug` only; do not add `Display`.
|
||||
- Out: distinct exit codes for auth failure. Worker bails clearly at startup if env is missing; server 401/403 mid-run flows through normal client error handling.
|
||||
- Out: blanket env scrubbing of trusted internal subprocesses (`gh auth token`, MCP servers, devcontainer setup, git). These may legitimately need credentials. Scrubbing scope: workflow/sandbox stage-execution chokepoint (`LocalSandbox::execute`) AND host-mode hooks (defense-in-depth; shell commands have no business reading the worker token).
|
||||
|
||||
## Threat Model
|
||||
|
||||
State the assumptions explicitly so reviewers and operators can challenge them.
|
||||
|
||||
- **Trust boundary:** the server process and any worker subprocess running under the same OS user are mutually trusted. The plan does not provide isolation between workers running as the same UID — a same-UID attacker (or a workflow stage that compromises the worker process) can read `FABRO_WORKER_TOKEN` from `/proc/<pid>/environ` on Linux. Multi-tenant deployments must use per-tenant OS users, per-tenant containers, or per-tenant namespaces for tenant isolation. Cross-run isolation between same-UID workers is NOT a property of this design.
|
||||
- **`SESSION_SECRET` is the master key.** It signs both user JWTs (via existing `derive_jwt_key` HKDF) and worker JWTs (via new `derive_worker_jwt_key` HKDF, distinct context label). Any leak vector — backup including `server.env`, environment dump in logs, ECS task definition exposure, accidental commit, breadcrumb capture — gives the attacker the ability to mint both kinds of tokens for any user / any run. Operational guidance: store `SESSION_SECRET` in a secrets manager, exclude from logs/Sentry, document a rotation procedure (rotation invalidates ALL outstanding worker tokens AND ALL user sessions — accept as the cost of compromise response). A separate `WORKER_JWT_SECRET` could narrow this — out of scope, called out in Open Questions.
|
||||
- **Worker token compromise (single run):** an attacker who exfiltrates a single worker token gains read/write on that one run's events/blobs/state for up to 72h or until terminal-status revocation, whichever first. Run-id binding limits cross-run damage. Server-side revocation set narrows the post-completion window (with the caveat in Risks: in-memory revocation does not survive server restart).
|
||||
- **`SESSION_SECRET` rotation as defense:** rotating `SESSION_SECRET` is the only operator-facing mechanism today to invalidate all outstanding worker tokens. Acceptable for emergency response; not a regular rotation cadence.
|
||||
- **Trust boundary:** the server process and any worker subprocess running under the same OS user are mutually trusted. Same-UID attackers (or a workflow stage that compromises the worker process) can read `FABRO_WORKER_TOKEN` from `/proc/<pid>/environ` on Linux. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers / namespaces. Cross-run isolation between same-UID workers is NOT a property of this design.
|
||||
- **`SESSION_SECRET` is the master key.** It signs both user JWTs and worker JWTs (via distinct HKDF context labels). Any leak vector — backup including `server.env`, env dump in logs, ECS task definition exposure, accidental commit, breadcrumb capture — gives the attacker the ability to mint tokens of either kind for any user / any run. Operational guidance: store `SESSION_SECRET` in a secrets manager, exclude from logs/Sentry, document a rotation procedure (rotation invalidates ALL outstanding worker tokens AND ALL user sessions — accept as the cost of compromise response).
|
||||
- **Worker token compromise:** an attacker who exfiltrates a single worker token gains read/write on that one run's events/blobs/state for up to 72h. Run-id binding limits cross-run damage. There is no in-product revocation; rotating `SESSION_SECRET` is the only mechanism to invalidate outstanding worker tokens.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `lib/crates/fabro-server/src/server.rs:291-293, 758-826, 828-869` — artifact-upload-token: claims struct, key generation (`OsRng` per boot), mint, "service token first, else user JWT" check (`authorize_artifact_upload`). This is the exact shape of the new worker token, generalized to all run-scoped routes.
|
||||
- `lib/crates/fabro-server/src/server.rs:3797-3851` — `worker_command`: single spawn site for both `start` and `resume`. Already passes `--artifact-upload-token` via argv. Replace with `--worker-token`.
|
||||
- `lib/crates/fabro-server/src/server.rs:4760` — `execute_run_subprocess` calls `worker_command` once per spawn; `RunExecutionMode` flows from `start_run` (`server.rs:4385`) and `create_run` (`server.rs:4156`). Single mint site covers both modes.
|
||||
- `lib/crates/fabro-server/src/auth/keys.rs:41` — `derive_jwt_key(secret: &[u8])` — existing HKDF helper for the user-JWT key. Mirror for worker JWT with distinct context label `b"fabro-worker-jwt-v1"` so worker keys survive server restarts.
|
||||
- `lib/crates/fabro-cli/src/commands/run/runner.rs:55-125, 239-295` — `__run-worker` entry, `HttpRunStore`, `HttpArtifactUploader`. Only seven server endpoints touched (catalogued below).
|
||||
- `lib/crates/fabro-cli/src/server_client.rs:51-58, 133-137, 312-335` — `connect_server_target_direct` → `connect_target_api_client_bundle` → `resolve_target_credential` → `AuthStore::default()`. Sole worker caller is `runner.rs:66`. Can be replaced for worker only via a sibling constructor.
|
||||
- `lib/crates/fabro-server/src/server.rs:287-289, 752-812, 813-854` — artifact-upload-token: claims struct, key generation (`OsRng` per boot), mint, "service token first, else user JWT" check (`authorize_artifact_upload`). Generalize the shape to all worker-touched routes; replace this mechanism wholesale.
|
||||
- `lib/crates/fabro-server/src/server.rs:3701-3755` — `worker_command`: single spawn site for both `start` and `resume`. Already passes `--artifact-upload-token` via argv (deleted in Unit 3) and calls `apply_worker_env` at `server.rs:3739`. Add `cmd.env("FABRO_WORKER_TOKEN", token)`.
|
||||
- `lib/crates/fabro-server/src/server.rs:4666` — `execute_run_subprocess` calls `worker_command` once per spawn; `RunExecutionMode` flows from `start_run` (`server.rs:4181`) and `create_run` (`server.rs:4011`). Single mint site covers both modes.
|
||||
- `lib/crates/fabro-server/src/spawn_env.rs:18` — existing `apply_worker_env` does `env_clear` + 8-name allowlist (PATH, HOME, TMPDIR, USER, RUST_LOG, RUST_BACKTRACE, FABRO_HOME, FABRO_STORAGE_ROOT). `SESSION_SECRET`, `FABRO_JWT_*`, `GITHUB_APP_*` are all already excluded. Existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) asserts `SESSION_SECRET` is stripped.
|
||||
- `lib/crates/fabro-server/src/auth/keys.rs:41` — existing HKDF helper `derive_jwt_key` for the user-JWT key. Mirror for worker JWT with distinct context label `b"fabro-worker-jwt-v1"` so worker keys survive server restarts (R3).
|
||||
- `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127` — `__run-worker` entry, `HttpRunStore`, `HttpArtifactUploader`. Only seven server endpoints touched (catalogued below).
|
||||
- `lib/crates/fabro-cli/src/server_client.rs:51-58, 133, 312` — `connect_server_target_direct` → `connect_target_api_client_bundle` → `resolve_target_credential` → `AuthStore::default()`. Sole worker caller is `runner.rs:67`. Replaced for the worker only via a sibling constructor.
|
||||
- `lib/crates/fabro-client/src/credential.rs:6-30` — `Credential` enum. Add `Worker(String)` variant; `bearer_token()` returns the string.
|
||||
- `lib/crates/fabro-types/src/run_event/mod.rs:29-81` — `ActorRef`/`ActorKind { User | Agent | System }`. No new variant needed — stamp worker events with `ActorKind::System`.
|
||||
- `lib/crates/fabro-types/src/run.rs:34-49, 68` — `RunProvenance`/`RunSubjectProvenance` already on `RunSpec`. Originator preserved at run-creation time; no schema change.
|
||||
- `lib/crates/fabro-workflow/src/event.rs:1340-1493, 2580-2603` — `stored_event_fields`/`to_run_event_at`: where `actor` is set on emitted events. Today most worker events ship `actor: None`; lifecycle events get user actor server-side; agent events get `ActorKind::Agent`. Default-fill at conversion time is the surgical change.
|
||||
- `lib/crates/fabro-types/src/run.rs:34-49` — `RunProvenance`/`RunSubjectProvenance` already on `RunSpec`. Originator preserved at run-creation time; no schema change.
|
||||
- `lib/crates/fabro-sandbox/src/local.rs:43-66, 221` — `LocalSandbox::execute` does `env_clear` + `should_filter_env_var` heuristic for stage commands. The `_token` suffix filter incidentally catches `FABRO_WORKER_TOKEN`; make it explicit (denylist entry).
|
||||
|
||||
### Worker → server endpoint surface (the surface that needs `authorize_run_scoped`)
|
||||
### Worker → server endpoint surface
|
||||
|
||||
These are the **only** routes that gain worker-token acceptance. Lifecycle/admin/list endpoints stay user-JWT-only (see Scope Boundaries).
|
||||
|
||||
| Worker call | HTTP | Path | Server handler | Auth today |
|
||||
|---|---|---|---|---|
|
||||
| `client.get_run_state` | GET | `/runs/{id}/state` | `get_run_state` (`server.rs:5179`) | `AuthenticatedService` |
|
||||
| `client.list_run_events` | GET | `/runs/{id}/events` | `list_run_events` (`server.rs:5249`) | `AuthenticatedService` |
|
||||
| `client.append_run_event` | POST | `/runs/{id}/events` | `append_run_event` (`server.rs:5199`) | `AuthenticatedService` |
|
||||
| `client.write_run_blob` | POST | `/runs/{id}/blobs` | `write_run_blob` (`server.rs:5455`) | `AuthenticatedService` |
|
||||
| `client.read_run_blob` | GET | `/runs/{id}/blobs/{blobId}` | `read_run_blob` (`server.rs:5482`) | `AuthenticatedService` |
|
||||
| `client.upload_stage_artifact_file` | POST | `/runs/{id}/stages/{stageId}/artifacts` (octet-stream) | `put_stage_artifact` (`server.rs:5941`) | `authorize_artifact_upload` |
|
||||
| `client.get_run_state` | GET | `/runs/{id}/state` | `get_run_state` (`server.rs:5076`) | `AuthenticatedService` |
|
||||
| `client.list_run_events` | GET | `/runs/{id}/events` | `list_run_events` (`server.rs:5146`) | `AuthenticatedService` |
|
||||
| `client.append_run_event` | POST | `/runs/{id}/events` | `append_run_event` (`server.rs:5096`) | `AuthenticatedService` |
|
||||
| `client.write_run_blob` | POST | `/runs/{id}/blobs` | `write_run_blob` (`server.rs:5352`) | `AuthenticatedService` |
|
||||
| `client.read_run_blob` | GET | `/runs/{id}/blobs/{blobId}` | `read_run_blob` (`server.rs:5379`) | `AuthenticatedService` |
|
||||
| `client.upload_stage_artifact_file` | POST | `/runs/{id}/stages/{stageId}/artifacts` (octet-stream) | `put_stage_artifact` (`server.rs:5838`) | `authorize_artifact_upload` |
|
||||
| `client.upload_stage_artifact_batch` | POST | same path (multipart) | same handler | same |
|
||||
|
||||
### Coordination with concurrent plans
|
||||
|
||||
- `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md` partially landed: `apply_worker_env` exists at `lib/crates/fabro-server/src/spawn_env.rs:22` and is already invoked from `worker_command` at `server.rs:3835`. The allowlist keeps `HOME`, so the env scrub alone does NOT fix the OAuth-from-disk steal. This plan is the worker-side fix. Per the user's "share" decision, both `apply_worker_env` (server-side) and the new `apply_sandbox_env` (worker-side) move into `fabro-util` so they share a single denylist constant — coordinate with whatever else of `2026-04-22-003` is still in flight.
|
||||
- `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md` partially landed: `apply_worker_env` exists at `lib/crates/fabro-server/src/spawn_env.rs:18` and is invoked from `worker_command` at `server.rs:3739`. The allowlist excludes server-only secrets, structurally preventing the worker from inheriting `SESSION_SECRET`. This plan adds the `FABRO_WORKER_TOKEN` re-injection alongside the existing `FABRO_DEV_TOKEN` re-injection (and ultimately replaces the latter).
|
||||
- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` Unit 8 created `lib/crates/fabro-server/src/auth/jwt.rs` (`Claims`, `issue`, `verify`, `JwtError`) and `auth/keys.rs::derive_jwt_key`. Reuse the HKDF derivation pattern (distinct context label) and the `jsonwebtoken` primitives directly — do not route worker-token claims through user-`JwtSubject`.
|
||||
- `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md` deliberately closed the "trust local files because same host" pattern. This plan preserves that closure — no new same-host exceptions; the worker uses an explicitly-passed token.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No `docs/solutions/` directory exists. Prior decisions live in `docs/plans/` (see above).
|
||||
- Artifact-upload-token TTL precedent is 24h (`server.rs:293`). New worker-token TTL is 72h (3× expansion) — justified because worker tokens must survive long human-in-the-loop pauses and there's no in-process refresh.
|
||||
- No `docs/solutions/` directory exists. Prior decisions live in `docs/plans/`.
|
||||
- Artifact-upload-token TTL precedent is 24h. New worker-token TTL is 72h — justified because worker tokens must survive long human-in-the-loop pauses with no in-process refresh.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Replace artifact-upload-token entirely; one JWT per run covers all run-scoped routes | Two parallel per-run JWTs is bookkeeping. Run-id binding gives the same blast-radius constraint without a separate scope. |
|
||||
| Pass JWT to worker via env var `FABRO_WORKER_TOKEN`, NOT CLI arg | Symmetric with existing `FABRO_DEV_TOKEN` re-injection model (`worker_command` at `server.rs:3836-3845`). Plays naturally with the `env_clear` + explicit re-injection model in `2026-04-22-003`. Avoids token strings in `ps` output. |
|
||||
| HS256, key derived from `SESSION_SECRET` via HKDF (context `b"fabro-worker-jwt-v1"`) | Workers must survive server restarts up to natural 72h expiry. `OsRng`-per-boot (artifact-upload precedent) defeats the long TTL. Distinct context label keeps it isolated from the user-JWT key. Operator rotation of `SESSION_SECRET` invalidates outstanding worker tokens — accepted. |
|
||||
| 72h TTL, no refresh | Decided by user. Long-paused runs need a generous outer ceiling. Resume re-mints. Workers running > 72h continuously fail loudly — acceptable outer bound. |
|
||||
| Replace artifact-upload-token entirely; one JWT per run covers all worker-touched run-scoped routes | Two parallel per-run JWTs is bookkeeping. Run-id binding gives the same blast-radius constraint without a separate scope. Greenfield → atomic delete, no shim. |
|
||||
| Pass JWT to worker via env var `FABRO_WORKER_TOKEN`. **Env-only — never argv.** | Symmetric with existing `FABRO_DEV_TOKEN` re-injection model. Plays naturally with `env_clear` + explicit re-injection in `apply_worker_env`. Avoids token strings in `ps` output. |
|
||||
| HS256, key derived from `SESSION_SECRET` via HKDF (context `b"fabro-worker-jwt-v1"`) | Workers must survive server restarts up to natural 72h expiry (R3). `OsRng`-per-boot defeats the long TTL. Distinct context label keeps it isolated from the user-JWT key. Operator rotation of `SESSION_SECRET` invalidates outstanding worker tokens — accepted (and is the only revocation mechanism). |
|
||||
| 72h TTL, no refresh, no revocation list | Long-paused runs need a generous outer ceiling. Resume re-mints. Workers running > 72h continuously fail loudly — acceptable outer bound. Adding revocation requires persistent state and creates restart-window contradictions; not worth the complexity for the current threat model. |
|
||||
| Per-run `run_id` claim, path-vs-claim check | Mirrors `maybe_authorize_artifact_upload_token`. Cross-run reuse → 403. |
|
||||
| Add `Credential::Worker(String)` variant (not reuse `DevToken`) | Debug printing stays accurate; type lets us prove "worker code only constructs `Worker`" structurally. |
|
||||
| New worker-only client constructor `connect_server_target_with_bearer(target, token)`, bypasses `AuthStore`/`OAuthSession` entirely | Worker should never read user OAuth. Surgical to fix at the worker callsite (one caller, `runner.rs:66`) rather than gating `resolve_target_credential` with a "are you a worker" flag. |
|
||||
| Worker default-fills `actor` on emitted events to `ActorRef { kind: System, id: Some("worker"), display: Some("system:worker") }` only when variant doesn't already set it | Lifecycle events keep user actor (set server-side at the lifecycle endpoint, not by worker). Agent events keep `ActorKind::Agent`. Surgical change in `to_run_event_at`. |
|
||||
| `authorize_run_scoped(parts, state, run_id)` is the single helper for all worker-touched routes | Replaces five `_auth: AuthenticatedService` extractors and the existing `authorize_artifact_upload`. One helper, one fall-through behavior. |
|
||||
| Delete artifact-upload-token mechanism atomically (no transition period) | Single-server, single-codebase change. No external consumers of the old token shape. In-flight workers survive deploy via user-JWT fall-through (they hold OAuth from `auth.json`); only newly-spawned post-deploy workers exercise the new contract — those start cleanly. Atomic swap. |
|
||||
| Server-only secrets (`SESSION_SECRET`, `FABRO_JWT_PRIVATE_KEY`, `GITHUB_APP_*`) must NOT leak to the worker process | **Already structurally mitigated**: `apply_worker_env` at `spawn_env.rs:22` does `env_clear` + an 8-name allowlist that excludes all of these. `worker_allowlist_is_fail_closed` test (`spawn_env.rs:80-113`) asserts `SESSION_SECRET` doesn't leak. Without this protection, an inherited `SESSION_SECRET` would let the worker derive `WorkerTokenKeys` locally and mint tokens for any run — defeating per-run binding. Verification: extend the existing fail-closed test to also assert `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` don't leak. |
|
||||
| Server-side per-run revocation set populated when run reaches terminal status | 72h compromise window is severe under the multi-tenant threat model. In-memory `DashSet<RunId>` checked in `authorize_worker_token` cuts post-completion blast radius to zero without changing the TTL ceiling. ~10 lines. |
|
||||
| Worker-side env scrubbing via single chokepoint helper, not per-call `env_remove` | Per-call rots: every new spawn site forgets. Codebase already uses chokepoint pattern in `LocalSandbox::execute` (allowlist + suffix filter). Mirror with `fabro-sandbox/src/spawn_env.rs::apply_sandbox_env(&mut Command)`, route remaining unscrubbed sites through it. `LocalSandbox::execute`'s incidental `_token`-suffix filter already catches `FABRO_WORKER_TOKEN` today — make that explicit (denylist entry, not coincidence). |
|
||||
| Worker exits with distinct non-zero code (`78` / `EX_NOPERM`) on missing/rejected `FABRO_WORKER_TOKEN`; emits `tracing::error!(target = "worker_auth", ...)` | Lets operators distinguish auth failures from generic crashes during deploy windows. Today all worker exit codes look identical at the server. |
|
||||
| Add `Credential::Worker(String)` variant (not reuse `DevToken`) | Debug printing stays accurate. No `Display` impl — compile-time hardening prevents accidental `format!`-leaks. |
|
||||
| New worker-only client constructor `connect_server_target_with_bearer(target, token)`, bypasses `AuthStore`/`OAuthSession` entirely | Worker should never read user OAuth. Surgical to fix at the worker callsite (one caller, `runner.rs:67`) rather than gating `resolve_target_credential` with a "are you a worker" flag. |
|
||||
| Stamp `system:worker` actor in a worker-side sink wrapper inside `RunEventSink::fanout`, NOT in `to_run_event_at` | `to_run_event_at` and `stored_event_fields` are shared with the server (server flushes lifecycle events through `workflow_event::to_run_event` at `server.rs:6702`). Default-filling there mis-stamps server-emitted events. The wrapper is worker-local. |
|
||||
| Route API is a set of typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`), NOT a bare helper function | Composes with existing `Json<...>` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path<String>` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. |
|
||||
| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |
|
||||
| `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. |
|
||||
| Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. |
|
||||
|
||||
### Worker-token vs artifact-upload-token (delta)
|
||||
|
||||
| Property | Artifact-upload-token (today) | Worker-token (new) |
|
||||
|---|---|---|
|
||||
| Coverage | One route (`/runs/{id}/stages/{stageId}/artifacts`) | All run-scoped routes the worker hits |
|
||||
| Coverage | One route (`/runs/{id}/stages/{stageId}/artifacts`) | All 7 worker-touched run-scoped routes |
|
||||
| TTL | 24h | 72h |
|
||||
| Signing key | `OsRng` at server boot, in-memory only | HKDF from `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"` |
|
||||
| Survives server restart | No | Yes (up to natural expiry) |
|
||||
|
|
@ -128,30 +132,23 @@ State the assumptions explicitly so reviewers and operators can challenge them.
|
|||
|
||||
### Resolved During Planning
|
||||
|
||||
- TTL: 72h (user). Refresh: none in-process; fresh mint at every spawn (start AND resume).
|
||||
- Key derivation: HKDF from `SESSION_SECRET` with context `b"fabro-worker-jwt-v1"` (user, this session).
|
||||
- Replace artifact-upload-token entirely vs. keep both: replace (user, this session).
|
||||
- TTL: 72h. Refresh: none in-process; fresh mint at every spawn (start AND resume).
|
||||
- Key derivation: HKDF from `SESSION_SECRET` with context `b"fabro-worker-jwt-v1"`.
|
||||
- Replace artifact-upload-token entirely vs. keep both: replace.
|
||||
- Token transport: env var (`FABRO_WORKER_TOKEN`), not argv.
|
||||
- Revocation: none. Per-run binding + 72h `exp` is the entire blast-radius bound.
|
||||
- New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely.
|
||||
- Stamp worker events server-side vs. worker-side: worker-side, in a dedicated `SystemWorkerActorSink` wrapper inside the worker's `RunEventSink::fanout` chain (NOT in the shared `to_run_event_at` converter, which is also called server-side). Keeps the converter pure (passthrough semantics preserved) and avoids mis-stamping server-emitted events.
|
||||
- Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain.
|
||||
- Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here.
|
||||
- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed.
|
||||
- Auth-failure exit codes: no — generic error handling.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact module name for new server-side worker-token machinery — likely `fabro-server/src/worker_token.rs`, decide at implementation.
|
||||
- Exact name of the new `Credential::Worker` variant on `fabro-client` — `Worker` likely, confirm against existing naming when implementing.
|
||||
- Exact module name for new server-side worker-token machinery — likely `fabro-server/src/worker_token.rs`.
|
||||
- Mechanism for the compile-time "no `Display` for `Credential::Worker`" guard — `static_assertions::assert_not_impl_any!` is the natural fit; choose at implementation time.
|
||||
- Whether to enforce "worker module never imports `AuthStore`" structurally (clippy `disallowed_types` on the `commands::run` module). Nice-to-have; defer.
|
||||
- Mechanism for the compile-time "no `Display` for `Credential::Worker`" guard — `static_assertions::assert_not_impl_any!` is the natural fit, but choose at implementation time based on whether the crate already pulls that dep.
|
||||
- Reaper for the in-memory revocation set — defer; entries are bounded by terminal-state runs and process lifetime is the natural reaper. Add only if real workloads show unbounded growth.
|
||||
- Core dump disable (`setrlimit(RLIMIT_CORE, 0)`) on the worker process to prevent token capture in crash dumps. Same-UID attacker assumption holds today; nice-to-have, defer.
|
||||
- Centralizing terminal-status writes through a single `mark_run_terminal(run_id, status)` chokepoint that both writes the status AND inserts into the revocation set (instead of grep-and-wire-by-hand). Cleaner, more auditable; defer pending implementation discovery of how dispersed the current writes are.
|
||||
- Whether `WORKER_JWT_SECRET` should be a separate operator-rotated secret (narrower than `SESSION_SECRET`) — defer; the SESSION_SECRET-as-master-key design is acceptable for the current threat model per Threat Model section, but the gap is documented for follow-up.
|
||||
|
||||
### Decisions surfaced by review (resolved)
|
||||
|
||||
- **Revocation persistence:** **document the gap, accept for now (option c).** In-memory revocation set, lost on restart. Combined with HKDF-derived signing key surviving restart, this leaves a known re-enablement window for tokens of completed runs across restarts. Documented in Risks and System-Wide Impact. Follow-up plan can add persistence if multi-tenant production demand surfaces it.
|
||||
- **Multi-token-per-run on rapid pause/resume:** **accept and document (option c).** Each resume mints a fresh 72h token; prior tokens stay valid up to natural `exp` or run terminal status. Multiplied compromise window is bounded by run-id (still scoped to one run). Documented in Risks. Follow-up plan can add per-spawn nonce if it becomes a real problem.
|
||||
- **Clippy lint extension to `tokio::process::Command::new`:** **no.** Do not extend the lint. Tokio-Command sites rely on code-review discipline to use `apply_sandbox_env`. Plan keeps `clippy.toml`'s existing `std::process::Command::new` denial only.
|
||||
- **Share `apply_sandbox_env` / `apply_worker_env`:** **share.** Place the helper in `fabro-util` (or a new dedicated crate if `fabro-util` becomes too dumping-ground), expose two thin wrappers — `fabro_util::process::apply_worker_env` (server-side, used by `worker_command`) and `fabro_util::process::apply_sandbox_env` (worker-side, used by all worker-reachable spawn sites). Both call the same underlying scrub function with the same denylist constant. Coordinate with `2026-04-22-003` Unit 3 — whichever plan lands first creates the helper; the other plan adds the second wrapper.
|
||||
- **Line-number drift in plan references:** addressed in this revision pass.
|
||||
- Core dump disable (`setrlimit(RLIMIT_CORE, 0)`) on the worker process. Same-UID attacker assumption holds today; defer.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
|
|
@ -162,28 +159,26 @@ sequenceDiagram
|
|||
participant Op as Operator
|
||||
participant Srv as fabro-server
|
||||
participant W as worker subprocess
|
||||
participant Child as sandbox/agent child
|
||||
participant Child as sandbox stage child
|
||||
|
||||
Op->>Srv: start (SESSION_SECRET in env)
|
||||
Note over Srv: HKDF-derive WorkerTokenKeys<br/>context "fabro-worker-jwt-v1"
|
||||
Srv->>Srv: spawn scheduled (start or resume)
|
||||
Note over Srv: issue_worker_token(run_id)<br/>HS256 + claims{run_id, scope, 72h}
|
||||
Srv->>W: spawn with env_clear + FABRO_WORKER_TOKEN injected<br/>(SESSION_SECRET / JWT_PRIVATE_KEY / GITHUB_* removed)
|
||||
Srv->>W: spawn with apply_worker_env (env_clear + allowlist)<br/>+ FABRO_WORKER_TOKEN injected
|
||||
W->>W: read FABRO_WORKER_TOKEN from env<br/>build Client with Credential::Worker(token)<br/>(no AuthStore, no OAuthSession)
|
||||
W->>Srv: POST /runs/{id}/events (Authorization: Bearer ...)
|
||||
Srv->>Srv: authorize_run_scoped:<br/>1) try worker token (run_id match + revocation check)<br/>2) else fall through to user-JWT extractor
|
||||
W->>Srv: POST /runs/{id}/events (Bearer ...)
|
||||
Srv->>Srv: authorize_run_scoped:<br/>1) try worker token (run_id match + exp check)<br/>2) else fall through to user-JWT extractor
|
||||
Srv-->>W: 200 OK
|
||||
W->>Child: spawn (apply_sandbox_env: scrub FABRO_WORKER_TOKEN)
|
||||
W->>Child: spawn stage command via LocalSandbox::execute<br/>(env_clear + safelist; FABRO_WORKER_TOKEN excluded)
|
||||
Child-->>W: result (no token in env)
|
||||
Note over W,Srv: ... run completes ...
|
||||
W->>Srv: POST /runs/{id}/events (terminal)
|
||||
Srv->>Srv: insert run_id into revocation set
|
||||
Note over Srv: server restart: HKDF re-derives same key,<br/>outstanding tokens still verify (up to natural exp)
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Worker JWT primitives (claims, keys, mint)**
|
||||
- [x] **Unit 1: Worker JWT primitives (claims, keys, mint)**
|
||||
|
||||
**Goal:** Server can mint a per-run worker JWT signed with a key derived from `SESSION_SECRET`. No callers yet.
|
||||
|
||||
|
|
@ -200,21 +195,25 @@ sequenceDiagram
|
|||
|
||||
**Approach:**
|
||||
- Constants: `WORKER_TOKEN_ISSUER = "fabro-server-worker"`, `WORKER_TOKEN_SCOPE = "run:worker"`, `WORKER_TOKEN_TTL_SECS = 72 * 60 * 60`.
|
||||
- `WorkerTokenClaims { iss, iat, exp, run_id, scope, jti }` — mirrors `ArtifactUploadClaims` plus a `jti` (random 128-bit hex) claim. `jti` enables per-token audit correlation in the future and lets logs distinguish two tokens minted for the same run (start vs resume) without leaking the token itself.
|
||||
- `WorkerTokenKeys { encoding, decoding, validation }` — mirrors `ArtifactUploadTokenKeys`. Built from a 32-byte HKDF output keyed by `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"`.
|
||||
- `WorkerTokenClaims { iss, iat, exp, run_id, scope, jti }` — `jti` (random 128-bit hex) enables audit correlation in logs without exposing the token.
|
||||
- `WorkerTokenKeys { encoding, decoding, validation }` — built from a 32-byte HKDF output keyed by `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"`.
|
||||
- `pub fn issue_worker_token(keys: &WorkerTokenKeys, run_id: &RunId) -> Result<String, ApiError>` — `jsonwebtoken::encode` with HS256.
|
||||
- Add `worker_tokens: WorkerTokenKeys` field on `AppState` next to `artifact_upload_tokens` (keep both during this unit; the artifact field is deleted in Unit 3).
|
||||
- `derive_worker_jwt_key(secret: &[u8]) -> [u8; 32]` in `auth/keys.rs` — same HKDF construction as `derive_jwt_key`, distinct `info` parameter.
|
||||
- `pub(crate) fn derive_worker_jwt_key(secret: &[u8]) -> Result<[u8; 32], KeyDeriveError>` in `auth/keys.rs` — same HKDF construction as `derive_jwt_key`, distinct `info` parameter (`b"fabro-worker-jwt-v1"`). Mirrors the existing helper's error shape so the `KeyDeriveError` cases (empty / too-short secret) propagate identically.
|
||||
- **App-state construction wires it explicitly**: `build_app_state` resolves `SESSION_SECRET` (already required for the user-JWT key today), calls `derive_worker_jwt_key`, and bails with a clear startup error if it fails. Failure modes: missing `SESSION_SECRET`, secret too short. Add `worker_tokens: WorkerTokenKeys` field on `AppState` next to `artifact_upload_tokens` (the artifact field is deleted in Unit 3).
|
||||
- **Test app-state builders** (`worker_command_test_state` at `server.rs:7868` and any other test fixture that constructs `AppState`) must supply a fixture `SESSION_SECRET`. The existing test secret used by user-JWT tests can be reused.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server.rs:291-293, 322-329, 758-780, 813-826` (artifact-upload-token, end-to-end).
|
||||
- `server.rs:287-289, 319-326, 752-773, 798-812` (artifact-upload-token, end-to-end).
|
||||
- `auth/keys.rs:41` (`derive_jwt_key` HKDF construction).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `issue_worker_token` produces a token; `jsonwebtoken::decode` with the same `WorkerTokenKeys` returns the expected `WorkerTokenClaims` (iss, scope, run_id).
|
||||
- Happy path: `issue_worker_token` produces a token; `jsonwebtoken::decode` with the same `WorkerTokenKeys` returns the expected `WorkerTokenClaims` (iss, scope, run_id, jti).
|
||||
- Edge case: token issued with `WorkerTokenKeys` derived from secret S verifies under a *fresh* `WorkerTokenKeys` derived from the same S — proves restart survival (R3).
|
||||
- Edge case: token issued under secret S1 fails to verify under keys derived from secret S2 (rotation invalidation).
|
||||
- Edge case: derivation context label `b"fabro-worker-jwt-v1"` produces a key materially different from `derive_jwt_key(secret)` (no accidental cross-acceptance with user JWTs).
|
||||
- Error path: `derive_worker_jwt_key(b"")` returns `Err(KeyDeriveError::Empty)`. Mirrors existing `derive_jwt_key` error shape.
|
||||
- Error path: `derive_worker_jwt_key(short_secret)` returns `Err(KeyDeriveError::TooShort { .. })` for secrets below the minimum length.
|
||||
- Startup: `build_app_state` with no `SESSION_SECRET` in env returns a startup error matching the existing user-JWT-key startup-error wording.
|
||||
|
||||
**Verification:**
|
||||
- All worker-token unit tests pass.
|
||||
|
|
@ -223,57 +222,64 @@ sequenceDiagram
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Server `authorize_run_scoped` helper (with revocation) + client `Credential::Worker` variant**
|
||||
- [x] **Unit 2: Server `AuthorizeRunScoped` extractor family + client `Credential::Worker` variant**
|
||||
|
||||
**Goal:** Single server-side helper accepts worker token (run-id-bound, not revoked) OR falls back to user JWT. Client crate gains a typed worker credential with no `Display` and redacted `Debug`.
|
||||
**Goal:** Three typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`) accept worker token (run-id-bound) OR fall back to user JWT. Client crate gains a typed worker credential with no `Display` and redacted `Debug`.
|
||||
|
||||
**Requirements:** R2, R7.
|
||||
|
||||
**Dependencies:** Unit 1.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/worker_token.rs` (add `authorize_worker_token`, `authorize_run_scoped`, `RevokedRunSet`)
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` (`AppState` field for `revoked_runs: RevokedRunSet`; populate in run-terminal-status sites)
|
||||
- Modify: `lib/crates/fabro-server/src/lib.rs` (re-export `authorize_run_scoped` if needed)
|
||||
- Modify: `lib/crates/fabro-server/src/worker_token.rs` (add `pub(crate) fn authorize_worker_token` internal helper; add the three public extractors `AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact` with `FromRequestParts` impls)
|
||||
- Modify: `lib/crates/fabro-server/src/lib.rs` (re-export the three extractors if needed by handler modules)
|
||||
- Modify: `lib/crates/fabro-client/src/credential.rs` (add `Worker(String)` variant — no `Display`)
|
||||
- Test: `lib/crates/fabro-server/src/worker_token.rs` (authorize helper tests inline)
|
||||
- Test: `lib/crates/fabro-client/src/credential.rs` (Debug + bearer_token tests inline)
|
||||
|
||||
**Approach:**
|
||||
- `authorize_worker_token(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<bool, ApiError>` — mirror `maybe_authorize_artifact_upload_token` (`server.rs:828-859`): on decode fail return `Ok(false)`; on scope mismatch / run_id mismatch / `state.revoked_runs.contains(run_id)` return `Err(ApiError::forbidden())`; on success `Ok(true)`.
|
||||
- `pub fn authorize_run_scoped(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<(), ApiError>` — mirror `authorize_artifact_upload` (`server.rs:861-869`): try worker token first, else `authenticate_service_parts(parts)`.
|
||||
- `RevokedRunSet` — wraps `Arc<DashSet<RunId>>` (or equivalent concurrent set). Bounded by terminal-state runs; entries can age out via a periodic reaper or simply persist for the process lifetime (simpler; bounded memory unless workload is pathological — defer reaper to a follow-up).
|
||||
- Wire revocation insertion at every site that transitions a run to a terminal status (find via grep for `RunStatus::Succeeded | Failed | Cancelled` writes in `fabro-server` and `fabro-workflow`-reduced events on the server side).
|
||||
- `Credential::Worker(String)` — `bearer_token() -> &str` returns the string; `Debug` prints `Credential::Worker(<redacted>)`. **Do NOT implement `Display`.** Compile-time hardening.
|
||||
- **Audit logging at authorize time:** on successful worker-token auth, emit `tracing::info!(target = "worker_auth", run_id = %run_id, jti = %claims.jti, "worker token accepted")`. On rejection (wrong run_id, wrong scope, revoked, bad signature), emit `tracing::warn!(target = "worker_auth", reason = %rejection_reason, "worker token rejected")`. Never log the token string itself — only `jti`. Supports incident response: operators can correlate accepted/rejected tokens to specific runs without a token database.
|
||||
- **Module placement & visibility**: `worker_token.rs` is a sibling module to `server.rs`; sibling modules cannot read private `AppState` fields. Two options: (a) keep the helper inside `impl AppState` in `server.rs` like `issue_artifact_upload_token` does today, or (b) put the helper in `worker_token.rs` and pass `&WorkerTokenKeys` directly (NOT `&AppState`). **Choose (b)** — keeps `AppState` internals private, makes the helper trivially testable without an `AppState` fixture, mirrors how `maybe_authorize_artifact_upload_token` already takes `&ArtifactUploadTokenKeys` not `&AppState`. The thin glue in `authorize_run_scoped` then takes `&AppState` and pulls `&state.worker_tokens` into the call.
|
||||
- `pub(crate) fn authorize_worker_token(parts: &Parts, run_id: &RunId, keys: &WorkerTokenKeys) -> Result<bool, ApiError>` — mirror `maybe_authorize_artifact_upload_token` (`server.rs:813-844`). Verification-first rule (no unverified claim peeking — `jsonwebtoken::decode` only returns claims after signature + expiry validation):
|
||||
- Bearer absent → `Ok(false)` silently.
|
||||
- `jsonwebtoken::decode` with `WorkerTokenKeys` returns `Err(_)` (any reason — bad signature, expired, malformed, alg mismatch) → `Ok(false)` silently. Could be a user JWT in fall-through, an expired worker token, or anything else; we don't know without verifying, and we don't peek at unverified payload bytes.
|
||||
- `decode` returns `Ok(claims)` AND `claims.scope != WORKER_TOKEN_SCOPE` → `Err(ApiError::forbidden())` + `tracing::warn!`. A token signed by us with a wrong scope is a misuse.
|
||||
- `decode` returns `Ok(claims)` AND `claims.run_id != run_id` → `Err(ApiError::forbidden())` + `tracing::warn!`. Cross-run reuse.
|
||||
- `decode` returns `Ok(claims)` AND scope + run_id match → `Ok(true)` + `tracing::info!`.
|
||||
- **Extractor body (shared logic)**: each `FromRequestParts` impl runs its path-parse step, then calls a shared `pub(crate)` helper that mirrors `authorize_artifact_upload` (`server.rs:846-854`): try `authorize_worker_token(parts, run_id, &state.worker_tokens)?`; if `Ok(false)`, fall through to `authenticate_service_parts(parts)`. `worker_tokens` stays `pub(crate)` on `AppState` so the helper in the same crate can read it.
|
||||
- `Credential::Worker(String)` — `bearer_token() -> &str` returns the string; `Debug` prints `Credential::Worker(<redacted>)`. **Do NOT implement `Display` on the `Credential` enum.** Compile-time hardening: assert `Credential: !Display` (variant-level assertions don't exist in Rust — the trait impl lives on the type).
|
||||
- **Audit logging at authorize time** (driven entirely by the verified-only rule above — no log if `decode` itself fails):
|
||||
- On successful worker-token auth: `tracing::info!(target = "worker_auth", run_id = %run_id, jti = %claims.jti, "worker token accepted")`.
|
||||
- On `Ok(claims)` with scope or run_id mismatch: `tracing::warn!(target = "worker_auth", reason = ..., jti = %claims.jti, "worker token rejected")`.
|
||||
- On `Err(_)` from `decode` (no claims available): silent. The bearer might be a user JWT in fall-through, an expired worker token, or garbage — we can't tell without verifying, and we don't try.
|
||||
- Never log the token string itself — only `jti`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server.rs:828-869` (artifact-upload-token authorize pattern).
|
||||
- `server.rs:813-854` (artifact-upload-token authorize pattern).
|
||||
- `fabro-client/src/credential.rs:6-40` (existing `DevToken`/`OAuth` variants).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (server): valid worker token for path run_id → `authorize_run_scoped` returns `Ok(())`.
|
||||
- Error path (server): worker token with `claims.run_id != path.run_id` → 403.
|
||||
- Error path (server): worker token with wrong scope → 403.
|
||||
- Error path (server): expired worker token → falls through to user-JWT path (returns false from worker check), and user-JWT extractor then rejects → 401.
|
||||
- Error path (server): bad signature → falls through to user-JWT path; user-JWT also rejects → 401.
|
||||
- Error path (server): `alg=none` JWT → rejected (validation requires HS256).
|
||||
- Error path (server, revocation): valid token but `run_id` in `revoked_runs` set → 403. Insert run_id into set, then re-attempt with a fresh-issued token for the same run → 403.
|
||||
- Happy path (server, unit): valid worker token for path run_id → `AuthorizeRunScoped` extractor succeeds; handler receives the parsed `RunId`.
|
||||
- Error path (server): worker token with `claims.run_id != path.run_id` → 403 + `worker_auth` warn with `jti`.
|
||||
- Error path (server): worker token signed with worker key but wrong scope → 403 + `worker_auth` warn.
|
||||
- Error path (server): expired worker token → `decode` returns `Err` → silent fall-through → user-JWT extractor rejects → 401. No `worker_auth` log.
|
||||
- Error path (server): bad signature (e.g. token signed with different key) → `decode` returns `Err` → silent fall-through → 401. No `worker_auth` log.
|
||||
- Error path (server): `alg=none` JWT → `decode` returns `Err` (validation requires HS256) → silent fall-through → 401.
|
||||
- Integration (server): no `Authorization` header → falls through to user-JWT extractor → 401 (no implicit acceptance).
|
||||
- Integration (server): valid user JWT, no worker token → user-JWT path accepts (R7).
|
||||
- Audit (precision): successful worker-token auth emits `target = "worker_auth"` info span with `run_id` and `jti`. Decode-success-but-claims-mismatch emits `warn` with `reason` and `jti`. Decode failure (any reason) emits NO `worker_auth` log — verify by counting log events on a user-JWT request and on an expired worker token; both must produce zero `worker_auth` lines.
|
||||
- Happy path (client): `Credential::Worker(s).bearer_token()` returns `s`.
|
||||
- Edge case (client): `Debug` impl prints `Credential::Worker(<redacted>)` — token string never appears in debug output.
|
||||
- Compile-time guard (client): assert `Credential::Worker` does not implement `Display` (e.g. via a `static_assertions::assert_not_impl_any!` or equivalent — defer exact mechanism to implementation, but the test must exist).
|
||||
- Compile-time guard (client): assert `Credential: !Display` at the type level (e.g. via `static_assertions::assert_not_impl_any!(Credential: std::fmt::Display)`). Variants are not types; the trait impl lives on the enum.
|
||||
|
||||
**Verification:**
|
||||
- All `authorize_run_scoped` tests pass with both branches and revocation exercised.
|
||||
- All extractor + helper tests pass with both branches exercised.
|
||||
- `cargo nextest run -p fabro-server -p fabro-client` succeeds.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: Wire all run-scoped routes through `authorize_run_scoped`; replace artifact-upload-token at the spawn**
|
||||
- [x] **Unit 3: Wire worker-touched routes through the `AuthorizeRunScoped` extractor family; replace artifact-upload-token at the spawn**
|
||||
|
||||
**Goal:** Every endpoint the worker hits accepts the worker token. Server spawn passes `--worker-token`. Old artifact-upload-token machinery deleted atomically.
|
||||
**Goal:** Each of the 7 worker-touched routes accepts the worker token. Server spawn injects `FABRO_WORKER_TOKEN` env var. Old artifact-upload-token machinery deleted atomically. Lifecycle/admin/SSE routes are NOT touched and continue to require user JWT.
|
||||
|
||||
**Requirements:** R1, R2, R4, R7.
|
||||
|
||||
|
|
@ -281,88 +287,131 @@ sequenceDiagram
|
|||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs`
|
||||
- Replace `_auth: AuthenticatedService` with explicit `authorize_run_scoped(&parts, &state, &id)?` call inside handler bodies for: `get_run_state` (5162), `list_run_events` (5232), `append_run_event` (5182), `write_run_blob` (5438), `read_run_blob` (5465). Add `parts: Parts` extractor where needed.
|
||||
- `put_stage_artifact` (5924): swap `authorize_artifact_upload` → `authorize_run_scoped`.
|
||||
- `worker_command` (3797-3851): replace `state.issue_artifact_upload_token` → `state.issue_worker_token`. Drop the `--artifact-upload-token <jwt>` arg entirely. Set `cmd.env("FABRO_WORKER_TOKEN", token)` unconditionally (always, regardless of auth method). Also `cmd.env_remove("FABRO_WORKER_TOKEN")` before re-injection (defense against parent-env leakage). Delete the conditional `cmd.env("FABRO_DEV_TOKEN", token)` block (3836-3845) and the preceding `cmd.env_remove("FABRO_DEV_TOKEN")` (keep an unconditional `env_remove` if Unit 3 of `2026-04-22-003` hasn't landed yet — coordinate; once `apply_worker_env` lands, `FABRO_WORKER_TOKEN` becomes the only authority-bearing re-injection).
|
||||
- `worker_command`: ensure `SESSION_SECRET`, `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` are all stripped from the worker env. The existing `apply_worker_env` at `spawn_env.rs:22` (already wired in at `server.rs:3835`) does `env_clear` + allowlist, so as long as the allowlist excludes these names (verify), they're already structurally absent. Add explicit `env_remove`s as belt-and-suspenders only if the audit reveals any leak. **Critical context**: without these strips, an inherited `SESSION_SECRET` lets the worker derive `WorkerTokenKeys` locally and mint tokens for any run. Verify allowlist excludes them — this is the highest-impact verification in the plan.
|
||||
- **Lifecycle/admin routes**: enumerate the routes the worker token must NOT reach and confirm none of them call `authorize_run_scoped` (they keep `_auth: AuthenticatedSubject` / `AuthenticatedService` directly): `cancel_run`, `pause_run`, `unpause_run`, `archive_run`, `unarchive_run`, `delete_run`, `submit_answer` (interview answers are end-user actions), `start_run`/`create_run`. Add a one-line code comment at each such handler: "// Worker token intentionally not accepted; this is a user/admin action."
|
||||
- Delete: `ARTIFACT_UPLOAD_TOKEN_*` constants (290-292), `ArtifactUploadClaims` (322-329), `ArtifactUploadTokenKeys` (315-320), `artifact_upload_token_keys` (813-826), `AppState::issue_artifact_upload_token` (758-780), `AppState::artifact_upload_tokens` field (568), `maybe_authorize_artifact_upload_token` (827-858), `authorize_artifact_upload` (860-869).
|
||||
- Test: existing tests in `lib/crates/fabro-server/src/server.rs` test module (rename / replace `worker_command_injects_dev_token_only_when_enabled` at 7911).
|
||||
- `get_run_state` (5076), `list_run_events` (5146), `append_run_event` (5096), `write_run_blob` (5352): replace `_auth: AuthenticatedService, Path(id): Path<String>` with `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors stay unchanged.
|
||||
- `read_run_blob` (5379): replace `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` with `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`.
|
||||
- `put_stage_artifact` (5838): replace `Path::<(String, String)>` + inline `authorize_artifact_upload(&parts, ...)` with `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact`. Keep `request: Request` for body extraction; continue to call `request.into_parts()` inside the handler for the header/body split.
|
||||
- `worker_command` (3701-3755): replace `state.issue_artifact_upload_token` → `state.issue_worker_token`. Drop the `--artifact-upload-token <jwt>` arg entirely. Set `cmd.env("FABRO_WORKER_TOKEN", token)` unconditionally (always, regardless of auth method). Also `cmd.env_remove("FABRO_WORKER_TOKEN")` before re-injection (defense against parent-env leakage). Delete the conditional `cmd.env("FABRO_DEV_TOKEN", token)` block (3740-3750) — `FABRO_WORKER_TOKEN` replaces it as the only authority-bearing re-injection.
|
||||
- **Lifecycle/admin/SSE routes left alone**: `cancel_run`, `pause_run`, `unpause_run`, `archive_run`, `unarchive_run`, `delete_run`, `submit_answer`, `start_run`, `create_run`, `attach_run_events` (`/runs/{id}/attach`), `attach_events` (`/attach`), and any list endpoint. Keep `_auth: AuthenticatedSubject` / `AuthenticatedService` directly. Add a one-line code comment at each of the 9 worker-rejecting handlers: `// Worker token intentionally not accepted; this is a user/admin action.`
|
||||
- Delete: `ARTIFACT_UPLOAD_TOKEN_*` constants (287-289), `ArtifactUploadClaims` (319-326), `ArtifactUploadTokenKeys` (313-317), `artifact_upload_token_keys` (798-812), `AppState::issue_artifact_upload_token` (752-773), `AppState::artifact_upload_tokens` field (565), `maybe_authorize_artifact_upload_token` (813-844), `authorize_artifact_upload` (846-854).
|
||||
- Test: existing tests in `lib/crates/fabro-server/src/server.rs` test module (rename / replace `worker_command_injects_dev_token_only_when_enabled` at 7836).
|
||||
|
||||
**Approach:**
|
||||
- All handler signature changes are mechanical: `_auth: AuthenticatedService` → take `parts: Parts` (or use `axum::extract::Request` + `into_parts`), call `authorize_run_scoped(&parts, &state, &id)?` near the top of the body.
|
||||
- The shape change is NOT trivially mechanical for JSON/body handlers like `append_run_event` (`Json<...>` body) or `write_run_blob` (`Bytes` body). Switching them to `Request::into_parts()` would require manually re-parsing the body. Instead, introduce a custom `FromRequestParts` extractor that composes naturally with body extractors.
|
||||
- **Route API is the extractor, not the helper.** Route handlers should use the new extractor types as their route-facing auth contract. `authorize_worker_token` and the inner decode/fall-through logic remain `pub(crate)` helpers used only by the extractors.
|
||||
- **Three extractor variants** (one per path shape the worker actually uses) in `worker_token.rs`:
|
||||
- `AuthorizeRunScoped(pub RunId)` — for `/runs/{id}/...` with a single run-id path param. Used by: `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`.
|
||||
- `AuthorizeRunBlob(pub RunId, pub RunBlobId)` — for `/runs/{id}/blobs/{blobId}`. Used by: `read_run_blob`.
|
||||
- `AuthorizeStageArtifact(pub RunId, pub StageId)` — for `/runs/{id}/stages/{stageId}/artifacts`. Used by: `put_stage_artifact` (and `list_stage_artifacts`, `get_stage_artifact` if they ever join the worker-touched set; not today).
|
||||
- Each `impl FromRequestParts` internally:
|
||||
- Extracts `Path::<(String, ...)>::from_request_parts` with the right tuple shape (1, 2, or 2 segments).
|
||||
- Parses each segment via the existing `parse_run_id_path`, `parse_run_blob_id_path`, `parse_stage_id_path` helpers.
|
||||
- Reads `AuthMode` from `parts.extensions` and `&AppState.worker_tokens` from axum state.
|
||||
- Calls `authorize_worker_token(parts, &run_id, &keys)?`; on `Ok(false)` falls through to `authenticate_service_parts(parts)`.
|
||||
- Returns the typed path params so handlers skip their own `Path<String>` + `parse_*` dance.
|
||||
- Handlers change:
|
||||
- `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`: `_auth: AuthenticatedService, Path(id): Path<String>` → `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors (`Json<...>`, `Bytes`) stay intact.
|
||||
- `read_run_blob`: `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` → `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`.
|
||||
- `put_stage_artifact`: currently takes `Request` + `Path::<(String, String)>`. Swap to `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact` + `request: Request` (body extraction stays manual via `request.into_parts()`).
|
||||
- Atomic swap, no transition period — cargo + tests catch any missed callsite.
|
||||
- **Pre-implementation audit (must run BEFORE coding starts):**
|
||||
1. Grep all `client.*` and `api.*` callsites under `lib/crates/fabro-cli/src/commands/run/` (and any helpers it transitively uses) → confirm each resolves to a server handler in the worker-touched table above. Adds rows for any newly-discovered handlers (e.g. heartbeat, status report) and wires them through `authorize_run_scoped`.
|
||||
2. Grep all internal repos and `docs/api-reference/fabro-api.yaml` for `artifact-upload-token`, `artifact_upload_token`, `--artifact-upload-token`, and `stage_artifacts:upload`. Document zero hits in the PR description before merging. If any external consumer exists, add a deprecation cycle instead of atomic delete.
|
||||
- **Structural rule:** `authorize_run_scoped` is invoked ONLY from handlers whose path contains `{id}` (`RunId`). Never invoke from non-run-scoped routes (list endpoints, admin endpoints). Verify by grep: every `authorize_run_scoped` callsite must be preceded by a path-extracted `RunId`.
|
||||
- **Pre-implementation audit:** grep all `client.*` and `api.*` callsites under `lib/crates/fabro-cli/src/commands/run/` (and any helpers it transitively uses) to confirm each resolves to one of the 7 endpoints in the table. If any newly-discovered handler exists, add a row and wire it through `AuthorizeRunScoped`.
|
||||
- **Structural rule:** `AuthorizeRunScoped` is used ONLY in handlers whose path contains `{id}` (`RunId`). Verify by grep: every usage must correspond to a `{id}` path segment.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `put_stage_artifact` handler (`server.rs:5941`) is the existing model: takes `parts: Parts`, calls `authorize_artifact_upload(&parts, &state, &id)?`.
|
||||
- `put_stage_artifact` handler (`server.rs:5838`) is the existing model: takes `parts: Parts`, calls `authorize_artifact_upload(&parts, &state, &id)?`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: each of the 5 newly-wired routes accepts a worker token whose `claims.run_id` matches the path `id` → expected response.
|
||||
- Error path: each route with worker token whose `claims.run_id` ≠ path `id` → 403.
|
||||
- Integration: each route with valid user JWT and no worker token → still works (R7 fall-through).
|
||||
- Replacement test for `worker_command_injects_dev_token_only_when_enabled`: rename to `worker_command_always_sets_worker_token_env`. Build `worker_command` for both `methods=["github"]` and `methods=["dev-token"]` settings; assert `FABRO_WORKER_TOKEN` env is set to a valid token in BOTH cases (no longer conditional on auth method). Assert `FABRO_DEV_TOKEN` env is NOT set in either case. Assert no `--artifact-upload-token` or `--worker-token` arg appears in argv (env-only).
|
||||
- Edge case (server secrets stripped): extend the existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:80-113`, currently asserts `SESSION_SECRET` and `MY_API_KEY` don't leak) to ALSO assert `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` don't leak. Critical: prevents the worker-mints-any-token escalation. The existing structure (env_clear + allowlist) already provides this; the test just needs to enumerate the additional names so future allowlist edits can't silently add them back.
|
||||
- Negative path (route enumeration): for each lifecycle/admin route (`cancel_run`, `pause_run`, `unpause_run`, `archive_run`, `unarchive_run`, `delete_run`, `submit_answer`), assert that presenting a valid worker token for the same `run_id` is rejected (handler still requires user JWT). Use the existing JWT-extractor test pattern (`jwt_auth.rs::valid_jwt_bearer_authenticates_with_identity` shape) as a model.
|
||||
- Negative path (SSE attach + non-run-scoped): assert a worker token is rejected on `/runs/{id}/attach`, `/attach`, `GET /runs` (list), `GET /usage`, `GET /sessions`, and any other non-run-scoped route discovered by the structural-rule grep above. Documents that worker authority does not bleed into list/admin surfaces.
|
||||
- Audit logging: assert that successful auth emits a `target = "worker_auth"` info span with `run_id` and `jti`; assert that rejection emits a `warn` span with `reason`. Use a tracing test subscriber. Confirms incident-response observability.
|
||||
- Replacement test for `worker_command_injects_dev_token_only_when_enabled`: rename to `worker_command_always_sets_worker_token_env`. Build `worker_command` for both `methods=["github"]` and `methods=["dev-token"]` settings; assert `FABRO_WORKER_TOKEN` env is set to a valid token in BOTH cases. Assert `FABRO_DEV_TOKEN` env is NOT set in either case. Assert no `--artifact-upload-token` or `--worker-token` arg appears in argv (env-only).
|
||||
- **Negative path (user-only route table):** for every route in the table below, assert presenting a valid worker token (with claims matching the run_id where applicable) is rejected. Tests MUST run under `AuthMode::Enabled` with a valid user-JWT key configured — under `AuthMode::Disabled`, `AuthenticatedService` accepts everything before any validation (`jwt_auth.rs:279`-style), so a "reject" assertion proves nothing. Model after the existing `jwt_auth.rs` tests that use `AuthMode::Enabled` with test secrets.
|
||||
|
||||
| Route | Handler | Expected status with worker token |
|
||||
|---|---|---|
|
||||
| `GET /runs` | `list_runs` | 401/403 |
|
||||
| `POST /runs` | `create_run` | 401/403 |
|
||||
| `GET /runs/resolve` | `resolve_run` | 401/403 |
|
||||
| `POST /preflight` | `run_preflight` | 401/403 |
|
||||
| `POST /graph/render` | `render_graph_from_manifest` | 401/403 |
|
||||
| `GET /attach` | `attach_events` | 401/403 |
|
||||
| `GET /boards/runs` | `list_board_runs` | 401/403 |
|
||||
| `GET /runs/{id}` | `get_run_status` | 401/403 |
|
||||
| `DELETE /runs/{id}` | `delete_run` | 401/403 |
|
||||
| `GET /runs/{id}/questions` | `get_questions` | 401/403 |
|
||||
| `POST /runs/{id}/questions/{qid}/answer` | `submit_answer` | 401/403 |
|
||||
| `GET /runs/{id}/attach` | `attach_run_events` | 401/403 |
|
||||
| `GET /runs/{id}/checkpoint` | `get_checkpoint` | 401/403 |
|
||||
| `POST /runs/{id}/cancel` | `cancel_run` | 401/403 |
|
||||
| `POST /runs/{id}/start` | `start_run` | 401/403 |
|
||||
| `POST /runs/{id}/pause` | `pause_run` | 401/403 |
|
||||
| `POST /runs/{id}/unpause` | `unpause_run` | 401/403 |
|
||||
| `POST /runs/{id}/archive` | `archive_run` | 401/403 |
|
||||
| `POST /runs/{id}/unarchive` | `unarchive_run` | 401/403 |
|
||||
| `GET /runs/{id}/graph` | `get_graph` | 401/403 |
|
||||
| `GET /runs/{id}/stages` | `list_run_stages` | 401/403 |
|
||||
| `GET /runs/{id}/artifacts` | `list_run_artifacts` | 401/403 |
|
||||
| `GET /runs/{id}/files` | `list_run_files` | 401/403 |
|
||||
| `GET /runs/{id}/stages/{stageId}/artifacts` | `list_stage_artifacts` | 401/403 |
|
||||
| `GET /runs/{id}/stages/{stageId}/artifacts/download` | `get_stage_artifact` | 401/403 |
|
||||
| `GET /runs/{id}/billing` | `get_run_billing` | 401/403 |
|
||||
| `GET /runs/{id}/settings` | `get_run_settings` | 401/403 |
|
||||
| `POST /runs/{id}/preview` | `generate_preview_url` | 401/403 |
|
||||
| `POST /runs/{id}/ssh` | `create_ssh_access` | 401/403 |
|
||||
| `GET /runs/{id}/sandbox/files` | `list_sandbox_files` | 401/403 |
|
||||
| `GET /runs/{id}/sandbox/file`, `PUT /runs/{id}/sandbox/file` | `get_sandbox_file`, `put_sandbox_file` | 401/403 |
|
||||
|
||||
Every route in the real-routes router (`server.rs:1162-1230`) except the 7 worker-touched routes is user-only. The acceptance criterion is a single test helper that iterates this explicit table and asserts rejection for each under `AuthMode::Enabled`. Routes that return `not_implemented` (turns, workflows, insights) are not included — they'll stay user-only automatically if ever implemented; flag in a follow-up if needed.
|
||||
- **Positive path for `put_stage_artifact`** (auth semantics changed — the only worker-touched route that previously had its own auth helper): explicit tests for the artifact upload route.
|
||||
- Valid worker token with matching `run_id` → 200 (octet-stream variant; multipart variant if cheap to set up).
|
||||
- Worker token with `claims.run_id != path.run_id` → 403.
|
||||
- Valid user JWT (no worker token) → 200 (R7 fall-through preserved on this route).
|
||||
- No bearer at all → 401.
|
||||
- Regression (env scrubbing): extend the existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) to assert `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` don't leak (alongside the existing `SESSION_SECRET` assertion).
|
||||
- Edge case: assert deleted symbols (`ArtifactUploadClaims`, `authorize_artifact_upload`, etc.) no longer exist — covered implicitly by `cargo build`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build --workspace` succeeds (no references to deleted artifact-upload-token symbols).
|
||||
- `cargo nextest run -p fabro-server` passes.
|
||||
- Server test for `worker_command` confirms `--worker-token` always present, `FABRO_DEV_TOKEN` never set.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 4: Worker (CLI) — use injected worker token from env, stop reading `AuthStore`**
|
||||
- [x] **Unit 4: Worker (CLI) — use injected worker token from env, stop reading `AuthStore`**
|
||||
|
||||
**Goal:** Worker subprocess reads its credential from `FABRO_WORKER_TOKEN` env at startup, uses it as its sole bearer for every server call, never constructs `AuthStore::default()`. Artifact uploads use the same client credential.
|
||||
**Goal:** Worker subprocess reads its credential from `FABRO_WORKER_TOKEN` env at startup, uses it as its sole bearer for every server call, and never constructs `AuthStore::default()`. The artifact uploader holds the same token string in a per-call bearer field (the client's upload methods require a per-call bearer argument today).
|
||||
|
||||
**Requirements:** R1, R5.
|
||||
|
||||
**Dependencies:** Unit 2 (`Credential::Worker` variant), Unit 3 (server sets `FABRO_WORKER_TOKEN` env on the worker subprocess).
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/args.rs:815` — DELETE the `artifact_upload_token: Option<String>` field on `RunWorkerArgs`. Do NOT add a replacement clap arg — the worker reads from env directly.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs:88-100` — drop the `artifact_upload_token` plumbing on the dispatch path.
|
||||
- Modify: `lib/crates/fabro-cli/src/args.rs:808` — DELETE the `artifact_upload_token: Option<String>` field on `RunWorkerArgs`. Do NOT add a replacement clap arg — the worker reads from env directly.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs:74-90` — drop the `artifact_upload_token` plumbing on the dispatch path.
|
||||
- Modify: `lib/crates/fabro-cli/src/server_client.rs` — add `pub(crate) async fn connect_server_target_with_bearer(target: &ServerTarget, bearer: &str) -> Result<Client>`. Builds `Client` with `.credential(Credential::Worker(bearer.to_owned()))`, no `oauth_session`, no `resolve_target_credential` call, no `AuthStore` access.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs`
|
||||
- At top of `execute`: read `FABRO_WORKER_TOKEN` from env via `std::env::var` and **immediately wrap in a `WorkerToken(SecretString)` newtype** (using `secrecy::SecretString` or a local equivalent: redacted `Debug`, no `Display`, no `Deref<Target=String>`). Never let the raw `String` escape the read site. Validate non-empty; bail with a clear error mentioning `FABRO_WORKER_TOKEN` if missing or empty. Drop `artifact_upload_token` from `execute`'s signature.
|
||||
- Modify: `lib/crates/fabro-util/src/exit.rs` — add `ExitClass::WorkerAuthRequired` mapping to integer `78` (`EX_NOPERM`). The runner's missing-token error is classified as this variant via `.classify(ExitClass::WorkerAuthRequired)` so the existing `exit_code_for` mapper produces 78 cleanly. Avoids a divergent `std::process::exit` path that would bypass the runner's tracing/JSON-output flow.
|
||||
- Line 66: replace `connect_server_target_direct(&server)` with `connect_server_target_with_bearer(&target, &worker_token)`.
|
||||
- Lines 76-80: build the artifact uploader without a separate `bearer_token` field — the client already carries the credential.
|
||||
- Delete `MissingArtifactUploadTokenUploader` (298-315) and the `match artifact_upload_token { Some/None }` fork (244-251); always construct `HttpArtifactUploader`.
|
||||
- `HttpArtifactUploader`: drop the `bearer_token: String` field; `upload_stage_artifacts` no longer threads a per-call bearer.
|
||||
- Modify: `lib/crates/fabro-client/src/client.rs:1053, 1091` — `upload_stage_artifact_*` no longer takes `bearer_token` parameter; uses the client's credential. (Confirm signature change is workable; if the client API forces per-call bearer for legacy reasons, leave the parameter and pass `client.credential().bearer_token()` from the worker side instead.)
|
||||
- Modify: `runner::execute` exits with code `78` (`EX_NOPERM`) via `ExitClass::WorkerAuthRequired` when `FABRO_WORKER_TOKEN` is missing or invalid; emits `tracing::error!(target = "worker_auth", ...)` so operators can grep for it. Distinct from generic crash exits.
|
||||
- Compile-time guards on `WorkerToken`: `static_assertions::assert_not_impl_any!(WorkerToken: Display, std::fmt::Display)` (or equivalent) — token cannot be `format!`-ed by accident. Test asserts `format!("{:?}", worker_token)` contains no substring of the actual token.
|
||||
- At top of `execute`: read `FABRO_WORKER_TOKEN` from env via `std::env::var`. Validate non-empty; bail with a clear error mentioning `FABRO_WORKER_TOKEN` if missing or empty (this is a server-bug indicator, not a user error). Drop `artifact_upload_token` from `execute`'s signature.
|
||||
- Line 67: replace `connect_server_target_direct(&server)` with `connect_server_target_with_bearer(&target, &worker_token)`.
|
||||
- Delete `MissingArtifactUploadTokenUploader` (300-317) and the `match artifact_upload_token { Some/None }` fork (~244-251); always construct `HttpArtifactUploader`.
|
||||
- `HttpArtifactUploader`: **keep** the per-call bearer field, rename `bearer_token: String` → `worker_token: String`. The client methods `upload_stage_artifact_file` and `upload_stage_artifact_batch` still require a per-call bearer parameter (no `Client::credential()` accessor exists today — see next bullet), so the uploader must hold the token and pass it per call. The token is the same string stored at client construction in `Credential::Worker`.
|
||||
- Keep `lib/crates/fabro-client/src/client.rs:1043, 1081` — `upload_stage_artifact_*` continue to take a `bearer_token` parameter. There is no `Client::credential()` accessor today (`credential` at `client.rs:176` is a builder setter, not a getter), so threading the token per-call is the path of least resistance. The worker-side caller passes the same `FABRO_WORKER_TOKEN` string it built the client with. (If a `Client::credential()` accessor is added later as a separate concern, the per-call parameter can be dropped then.)
|
||||
|
||||
**Child-process env scrub (chokepoint pattern, NOT per-call):**
|
||||
**Stage-execution env scrubbing (narrow scope):**
|
||||
|
||||
The codebase already has a chokepoint at `LocalSandbox::execute` (`lib/crates/fabro-sandbox/src/local.rs:223-242`): `env_clear` + `should_filter_env_var` heuristic with safelist + suffix denylist. `FABRO_WORKER_TOKEN` is incidentally caught by the `_token` suffix filter today — make this explicit (denylist entry, not coincidence). Then add a sibling helper for the unscrubbed sites.
|
||||
The spawn site that runs user-supplied workflow stage commands must NOT see `FABRO_WORKER_TOKEN`: `LocalSandbox::execute` (`lib/crates/fabro-sandbox/src/local.rs:221`). It already does `env_clear` + safelist (`local.rs:43-66`) with a `_token` suffix denylist that incidentally catches `FABRO_WORKER_TOKEN`.
|
||||
|
||||
- Modify: `lib/crates/fabro-sandbox/src/local.rs:43-66` — add `"FABRO_WORKER_TOKEN"` (and `SESSION_SECRET`, `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) to an explicit denylist alongside the suffix heuristic. Comment why: future rename like `FABRO_WORKER_AUTH` would silently leak under the suffix heuristic alone.
|
||||
- Create or extend: `lib/crates/fabro-util/src/process.rs` (new module) exposing both `pub fn apply_worker_env(cmd: &mut Command)` (server-side, used by `worker_command`) AND `pub fn apply_sandbox_env(cmd: &mut Command)` (worker-side, used by spawn sites below). Both call the same underlying scrub against the same denylist constant. **Shared with `2026-04-22-003`** — whichever plan lands first creates the module; the other adds the second wrapper. The denylist constant lives here too, single source of truth for "secret env names that must not leak across the worker boundary." Both `tokio::process::Command` and `std::process::Command` supported (the function takes a trait or has two overloads).
|
||||
- Modify: route the following unscrubbed worker-reachable spawn sites through `apply_sandbox_env`:
|
||||
- `lib/crates/fabro-sandbox/src/local.rs:334, 355, 504` — `rg`, `grep`, `uname`.
|
||||
- `lib/crates/fabro-workflow/src/git.rs:35` (`git_cmd` helper — single chokepoint for git lines `342, 347, 414`).
|
||||
- `lib/crates/fabro-workflow/src/transforms/file_inlining.rs:124, 129` — git invocations.
|
||||
- `lib/crates/fabro-workflow/src/sandbox_git.rs` — production git sites (skip `#[cfg(test)]` blocks at 800-1053).
|
||||
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs:417` — devcontainer `initializeCommand` (`sh -c`).
|
||||
- `lib/crates/fabro-devcontainer/src/features.rs:67, 80, 113, 150, 199` — `which`, `brew`, `sh -c curl|tar`, `tar`, `oras pull`.
|
||||
- `lib/crates/fabro-mcp/src/client.rs:47` — stdio MCP server subprocess.
|
||||
- `lib/crates/fabro-github/src/lib.rs:129` — `gh auth token`.
|
||||
- `lib/crates/fabro-hooks/src/executor.rs:187` — non-sandbox hook `sh -c`.
|
||||
- Note: `fabro-agent` constructs zero `Command`s (all exec routes through `Sandbox::exec_command`). Fixing `LocalSandbox` covers the entire agent surface.
|
||||
- Note: existing `clippy.toml` denies `std::process::Command::new` workspace-wide but does NOT deny `tokio::process::Command::new`. Most worker-reachable spawn sites use tokio Command, so the lint nudge is partial — tokio sites rely on code-review discipline to use `apply_sandbox_env`. Decision: do not extend the lint here (out of scope; bigger workspace-wide audit). Code review enforces tokio-side hygiene.
|
||||
- **Critical: filter both inherited env AND explicit `env_vars` extras.** `LocalSandbox::execute` (`local.rs:224`) appends caller-supplied `env_vars` after the inherited-env filter, so a stage config with `env_vars` containing `FABRO_WORKER_TOKEN` would still leak. Apply the same denylist to the `env_vars` extras path: drop any key in the denylist before calling `cmd.env(key, value)` on each extra.
|
||||
|
||||
**Hooks (host mode) — surgical scrub:**
|
||||
|
||||
`fabro-hooks/src/executor.rs:186` runs `sh -c <hook command>` for non-sandbox hooks and inherits the worker process env. Even though hooks are operator-configured (not random user input), they are still shell commands that have no business reading `FABRO_WORKER_TOKEN`.
|
||||
|
||||
- Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env.
|
||||
|
||||
**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.
|
||||
|
||||
**Approach:**
|
||||
- `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged.
|
||||
- The new constructor is the *only* path the worker takes; verify by grep that `commands::run::runner` and `commands::run::mod` are the only modules importing it.
|
||||
- The new constructor is the *only* path the worker takes; verify by grep that `commands::run::runner` is the only module importing it.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `server_client.rs:80-110` (`connect_managed_unix_socket_api_client_bundle`) for the client-builder shape; new constructor is a stripped-down version.
|
||||
|
|
@ -374,9 +423,9 @@ The codebase already has a chokepoint at `LocalSandbox::execute` (`lib/crates/fa
|
|||
- Integration: `runner::execute` with `FABRO_WORKER_TOKEN=<jwt>` set in env POSTs an event using only the injected token; no fallback to `auth.json`.
|
||||
- Error path: `runner::execute` invoked without `FABRO_WORKER_TOKEN` in env returns a clear error mentioning the variable name; does NOT silently fall back to user OAuth.
|
||||
- Edge case: `RunWorkerArgs` no longer has `artifact_upload_token` field — `cargo build` confirms.
|
||||
- Integration (env hygiene, sandbox path): a workflow stage Bash command `env | grep -E "FABRO_WORKER_TOKEN|SESSION_SECRET|FABRO_JWT_PRIVATE_KEY"` prints empty output. Covers `LocalSandbox::execute` denylist correctness.
|
||||
- Integration (env hygiene, non-sandbox path): a unit test on `apply_sandbox_env` builds a `Command`, sets the denylist names in the parent test env, calls the helper, then asserts each name is `Removed` in the resulting `Command`'s env overrides.
|
||||
- Error path (exit code): `runner::execute` invoked with no `FABRO_WORKER_TOKEN` exits with status 78 and emits a `target = "worker_auth"` tracing line. Verify via process spawn in a small integration test.
|
||||
- Integration (env hygiene, sandbox path, **inherited**): worker env has `FABRO_WORKER_TOKEN=<jwt>` set; a workflow stage Bash command `env | grep -E "FABRO_WORKER_TOKEN|SESSION_SECRET|FABRO_JWT_PRIVATE_KEY"` prints empty output. Covers `LocalSandbox::execute` denylist correctness for inherited env.
|
||||
- Integration (env hygiene, sandbox path, **explicit env_vars**): a workflow stage configured with `env_vars: { FABRO_WORKER_TOKEN: "leaked", MY_VAR: "ok" }` produces a child env where `FABRO_WORKER_TOKEN` is absent but `MY_VAR=ok` is present. Covers the explicit-extras filter path. **Without this test, the explicit-env_vars bypass is undetected.**
|
||||
- Integration (env hygiene, hooks): a host-mode hook (`fabro-hooks/src/executor.rs:186`) spawned with `FABRO_WORKER_TOKEN` in the worker's env produces a child where `env | grep FABRO_WORKER_TOKEN` is empty.
|
||||
|
||||
**Verification:**
|
||||
- `cargo build --workspace` succeeds.
|
||||
|
|
@ -385,66 +434,80 @@ The codebase already has a chokepoint at `LocalSandbox::execute` (`lib/crates/fa
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 5: Stamp `system:worker` actor on worker-emitted events**
|
||||
- [x] **Unit 5: Stamp `system:worker` actor on worker-emitted events (worker-side sink wrapper)**
|
||||
|
||||
**Goal:** Events the worker emits without a typed actor get a `system:worker` stamp at the *worker-side sink layer*, not in shared event-conversion infrastructure. User identity stays on the run record (`RunSpec.provenance.subject`), where it already lives.
|
||||
**Goal:** Events the worker emits without a typed actor get a `system:worker` stamp at the *worker-side sink layer*. User identity stays on the run record (`RunSpec.provenance.subject`), where it already lives.
|
||||
|
||||
**Requirements:** R6.
|
||||
|
||||
**Dependencies:** Should land WITH the auth changes (Units 1-4), not in isolation. Landing this alone produces a wire-visible behavior change (worker events flip from `actor: None` to `actor: System(worker)`) without the auth context that justifies it. Sequence: land Unit 5 in the same release as Units 1-4 to keep the rationale visible in git history.
|
||||
**Dependencies:** Should land WITH the auth changes (Units 1-4), not in isolation. Landing alone produces a wire-visible behavior change (worker events flip from `actor: None` to `actor: System(worker)`) without the auth context that justifies it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs` — install a worker-only sink wrapper in the `RunEventSink::fanout` chain at `runner.rs:100` that default-fills `actor` for events emitted by the worker.
|
||||
- Test: `lib/crates/fabro-cli/src/commands/run/runner.rs` (test module inline).
|
||||
- Modify: `lib/crates/fabro-workflow/src/event.rs` — add `RunEventSink::Map { transform, inner }` variant that applies `transform` to each event before forwarding to `inner`. Wire it into the existing dispatch logic so all events reach the inner sink already transformed.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs` — at the `RunEventSink::fanout([...])` construction site (`runner.rs:100`), wrap the fanout in `RunEventSink::Map { transform: stamp_system_worker, inner: ... }` so the stamp applies to all downstream sinks (backend HTTP, local callback, future).
|
||||
- Test: `lib/crates/fabro-workflow/src/event.rs` (test the `Map` variant in isolation).
|
||||
- Test: `lib/crates/fabro-cli/src/commands/run/runner.rs` (test the worker-local stamp wrapper end-to-end).
|
||||
|
||||
**Approach:**
|
||||
- **Critical: do NOT add the default-fill to `lib/crates/fabro-workflow/src/event.rs::to_run_event_at` or `stored_event_fields`.** Those helpers are shared infrastructure called by both the server (e.g. `server.rs:6805` lifecycle/error event flush via `workflow_event::to_run_event`) AND the worker. Default-filling there mis-stamps server-emitted events as `system:worker`.
|
||||
- Helper function (not `const` — `ActorRef::id`/`display` are `Option<String>`, heap-allocated, not `const`-constructible): `fn system_worker_actor() -> ActorRef { ActorRef { kind: ActorKind::System, id: Some("worker".to_string()), display: Some("system:worker".to_string()) } }` lives in `runner.rs` (worker-local).
|
||||
- Wrap `RunEventSink::backend(http_store)` in a `SystemWorkerActorSink` adapter that, before forwarding to the inner sink, applies the rule: **if `event.actor.is_none()`, fill with `system_worker_actor()`** (value-based, not variant-based). Variants like `RunArchived { actor: Some(user_actor) }` keep their actor unchanged because `actor.is_some()`. Variants like worker self-cancel `RunCancelRequested { actor: None }` correctly get the system actor.
|
||||
- Agent events (`AssistantMessage`) are constructed with `actor: Some(ActorRef::agent(...))` upstream — they retain `ActorKind::Agent` because `actor.is_some()`.
|
||||
- **Critical: do NOT modify `lib/crates/fabro-workflow/src/event.rs::to_run_event_at` or `stored_event_fields`.** Those helpers are shared with the server (e.g. `server.rs:6702` flushes lifecycle events through `workflow_event::to_run_event`). Default-filling there mis-stamps server-emitted events.
|
||||
- Helper function (not `const` — `ActorRef::id`/`display` are `Option<String>`, heap-allocated): `fn system_worker_actor() -> ActorRef { ActorRef { kind: ActorKind::System, id: Some("worker".to_string()), display: Some("system:worker".to_string()) } }` lives in `runner.rs` (worker-local).
|
||||
- **Stamping must apply to the whole fanout, not just one sink variant.** `RunEventSink` is an enum (`Backend | Callback | Composite | …`) in `fabro-workflow/src/event.rs`. Wrapping only the `Backend` variant means the local callback (today: `update_worker_title_from_event`) and any future sink see unstamped events.
|
||||
- **Design: add `RunEventSink::Map { transform: Arc<dyn Fn(RunEvent) -> RunEvent + Send + Sync>, inner: Box<RunEventSink> }`** variant. Worker constructs `RunEventSink::Map { transform: stamp_system_worker, inner: Box::new(RunEventSink::fanout([backend, callback])) }`; stamp applies before the fanout splits.
|
||||
- **Dispatch: non-recursive, iterative, owned per branch.** The existing `write_run_event` at `event.rs:2698` uses an iterative stack with a single shared `&RunEvent`. Naively translating `Map => inner.write_run_event(&mapped).await` would introduce recursive async (doesn't compile cleanly without boxing each recursive call, and obscures the shape).
|
||||
- Redesign the traversal to carry **`(sink, owned_event)` pairs** on the stack instead of `(&sink, &event)`. Each node owns the `RunEvent` value for its subtree.
|
||||
- `Map { transform, inner }`: apply `transform` to the owned event → push `(*inner, transformed_event)` onto the stack. The branch downstream sees the new event; the original is dropped when this stack frame unwinds.
|
||||
- `Composite { sinks }`: for each child sink, push `(child, event.clone())`. Each branch gets its own owned event. (Cloning `RunEvent` is cheap — it's a struct of owned data already serialized once; no deep-copy of large payloads.)
|
||||
- `Backend { ... }` / `Callback { ... }`: terminal — invoke with the owned event, no recursion.
|
||||
- Keep the existing async-loop shape; only the stack element type changes.
|
||||
- The transform applies the value-based rule: **if `event.actor.is_none()`, fill with `system_worker_actor()`**. Agent events (`AssistantMessage`) keep `ActorKind::Agent` because `actor.is_some()`. Worker self-cancel events (`Event::RunCancelRequested { actor: None }`) correctly get the system actor.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `RunEventSink::fanout` composition (`fabro-workflow/src/event.rs` sink layer) — sink wrappers are the existing pattern for per-deployment behavior.
|
||||
- Existing actor-stamping for lifecycle events at the server endpoints (`actor_from_subject` at `server.rs:6278`) — server-side stamping for user actions; worker-side wrapper for worker events. Symmetric.
|
||||
- `RunEventSink::fanout` composition (`fabro-workflow/src/event.rs` sink layer).
|
||||
- Existing actor-stamping for lifecycle events at the server endpoints (`actor_from_subject` at `server.rs:6175`) — server-side stamping for user actions; worker-side wrapper for worker events.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: a stage-execution `RunEvent { actor: None, ... }` flows through `SystemWorkerActorSink` → forwarded event has `actor: Some(ActorRef { kind: System, id: Some("worker"), display: Some("system:worker") })`.
|
||||
- Edge case: `RunEvent { actor: Some(user_actor), ... }` (e.g. lifecycle event mirrored back) → forwarded event retains the user actor (`actor.is_some()` → no-op).
|
||||
- Edge case: agent message `RunEvent { actor: Some(ActorKind::Agent), ... }` → forwarded event retains `ActorKind::Agent`.
|
||||
- Edge case: worker self-cancel `RunEvent { actor: None, body: RunCancelRequested { ... } }` → forwarded event gets `system:worker`. (Catches the variant-vs-value-based bug from earlier draft.)
|
||||
- Regression: server-side event flush via `workflow_event::to_run_event` (`server.rs:6805`) is unchanged — `to_run_event_at` retains its passthrough semantics. Verify by leaving `to_run_event_at` tests untouched.
|
||||
- Regression: `create_hydrates_provenance_into_store_state` (`fabro-workflow/src/operations/create.rs:1037`) still passes — originator user identity still on `RunSpec.provenance.subject`.
|
||||
- Happy path (`Map` variant): a `RunEventSink::Map { transform: |e| e.with_actor(ActorRef::user("alice")), inner: backend }` applied to an event with any actor → forwarded event has actor = `ActorRef::user("alice")` regardless. Confirms the variant works.
|
||||
- Happy path (worker stamp): a stage-execution `RunEvent { actor: None, ... }` enters the wrapped fanout → BOTH the backend sink AND the local callback see `actor: Some(ActorRef { kind: System, id: Some("worker"), display: Some("system:worker") })`.
|
||||
- Edge case: `RunEvent { actor: Some(user_actor), ... }` → both sinks retain the user actor (`actor.is_some()` → no-op).
|
||||
- Edge case: agent message `RunEvent { actor: Some(ActorKind::Agent), ... }` → both sinks retain `ActorKind::Agent`.
|
||||
- Edge case: worker self-cancel `RunEvent { actor: None, body: RunCancelRequested { ... } }` → both sinks get `system:worker`.
|
||||
- Per-sink uniformity assertion: construct the worker's actual fanout (Backend + Callback), feed an `actor: None` event in, capture what each sink receives, assert both have `system:worker`. Without this test, only stamping the Backend variant could regress without detection.
|
||||
- Regression: server-side event flush via `workflow_event::to_run_event` (`server.rs:6702`) is unchanged — `to_run_event_at` retains its passthrough semantics.
|
||||
- Regression: `create_hydrates_provenance_into_store_state` (`fabro-workflow/src/operations/create.rs`) still passes — originator user identity still on `RunSpec.provenance.subject`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-workflow` passes.
|
||||
- `cargo nextest run -p fabro-cli` passes.
|
||||
- New tests for the default-fill and the override-protections both pass.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 6: End-to-end regression — github-only worker run with no `~/.fabro/auth.json`**
|
||||
- [x] **Unit 6: End-to-end regression — github-only worker run with no `~/.fabro/auth.json`**
|
||||
|
||||
**Goal:** Lock in the bug fix: a github-only deployment can spawn a worker that completes a run, with no user OAuth artifact present on the worker host.
|
||||
**Goal:** Lock in the bug fix: a github-only deployment can spawn a worker that completes a run, with no user OAuth artifact present on the worker host. Worker authenticates using only `FABRO_WORKER_TOKEN`.
|
||||
|
||||
**Requirements:** R1, R5.
|
||||
|
||||
**Dependencies:** Units 1-5.
|
||||
|
||||
**Files:**
|
||||
- Create: an integration test under `lib/crates/fabro-cli/tests/it/cmd/` (existing test scaffolding) — file name per local convention (e.g. `worker_auth.rs` or `cmd/run_worker_auth_test.rs`).
|
||||
- Create: an integration test under `lib/crates/fabro-cli/tests/it/cmd/` (existing test scaffolding) — file name per local convention (e.g. `worker_auth.rs`).
|
||||
|
||||
**Approach:**
|
||||
- Test fixture: server configured with `auth.methods = ["github"]` only; no `FABRO_DEV_TOKEN`; `FABRO_HOME` redirected to a fresh tempdir with no `auth.json`.
|
||||
- **Test fixture:**
|
||||
- Server configured with `auth.methods = ["github"]` only; no `FABRO_DEV_TOKEN` in `server.env`.
|
||||
- `FABRO_HOME` redirected to a fresh tempdir on the *worker* side (no `auth.json`, no `dev-token` file).
|
||||
- Submitter path uses an authenticated test user JWT (minted directly via `auth/jwt.rs::issue` with the test `SESSION_SECRET`) to call `POST /runs` and start the run. **Do not** confuse this with the worker's auth — the user JWT is what authorizes run creation; the worker JWT (server-issued in response) is what the worker subprocess uses.
|
||||
- Run a tiny workflow end-to-end via the daemon → worker spawn path.
|
||||
- Assert the worker successfully POSTs at least one `RunEvent` and the run reaches a terminal status.
|
||||
- Assert no `auth.json` file is touched (timestamp / non-existence).
|
||||
- Assert `~/.fabro/auth.json` is not touched (non-existence at the redirected `FABRO_HOME`).
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing integration tests in `lib/crates/fabro-cli/tests/it/cmd/` (per `support.rs` helpers like `daemon.bind.to_target()` at `support.rs:718`).
|
||||
- Existing integration tests in `lib/crates/fabro-cli/tests/it/cmd/` (per `support.rs` helpers like `daemon.bind.to_target()`).
|
||||
- CLAUDE.md note: tests must use `.no_proxy()` HTTP clients.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: github-only server + no `auth.json` + minimal workflow → run completes successfully; events visible via `GET /runs/{id}/events`.
|
||||
- Integration: same setup but worker token tampered (e.g. spawn with a manually-replaced bogus token) → worker fails fast on first server call.
|
||||
- Integration: github-only server + no `auth.json` on worker host + minimal workflow → run completes successfully; events visible via `GET /runs/{id}/events`.
|
||||
- Integration: same setup but worker spawned with a deliberately-bogus `FABRO_WORKER_TOKEN` (e.g. valid HS256 but wrong `run_id` claim) → worker fails fast on first server call.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it` passes the new test.
|
||||
|
|
@ -452,67 +515,56 @@ The codebase already has a chokepoint at `LocalSandbox::execute` (`lib/crates/fa
|
|||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Worker subprocess no longer reads `~/.fabro/auth.json`. CLI user-facing commands (`fabro run`, `fabro ps`, `fabro auth`, etc.) unchanged — they still go through `connect_server_target` / `connect_server_with_settings`. SSE attach endpoints (`/runs/{id}/attach`, `/attach`) unchanged: worker is a producer, not a consumer; user-JWT-only auth on those routes preserved.
|
||||
- **Error propagation:** Worker token expiry mid-run → next server call returns 401, worker exits 78 (`EX_NOPERM`), server marks run failed via existing `pump_worker_*` paths (`server.rs:4880`+). Distinct exit code separates auth failures from generic crashes. Worker token rejected as revoked after run terminal status → 403, same exit path.
|
||||
- **State lifecycle risks:** Server restart with HKDF-derived key: outstanding worker tokens remain valid up to natural expiry. Server restart with `SESSION_SECRET` rotated: outstanding workers fail at next call (acceptable; matches user-session invalidation). Server-side revocation set is in-memory and lost on restart — combined with token survival across restart, this creates a post-restart re-enablement window for tokens of completed runs. See Risks for mitigation options. The previous draft's claim that revocation "cuts blast radius to zero" was wrong; the honest claim is "cuts post-completion blast radius to zero **between server restarts**."
|
||||
- **Event sink uniformity:** Worker `RunEventSink::fanout([Store(http), Callback])` (`runner.rs:100`) — default-fill of `actor` happens upstream in `to_run_event_at`, so all sinks (HTTP backend, local callback, future telemetry) see the same `system:worker` actor. No per-sink divergence.
|
||||
- **API surface parity:** `RunEvent.actor` shape unchanged (`ActorRef` already has `ActorKind::System`); worker-emitted events newly carry `system:worker` instead of `None`. Web UI audit (`apps/fabro-web/app/`) found ZERO references to `actor` or `author` — there's nothing to break or filter today. Risk of UI breakage was overstated in earlier draft; confirmed safe.
|
||||
- **Worker-process trust degradation:** A workflow stage that compromises the worker process (malicious shell, code injection) gains read access to `FABRO_WORKER_TOKEN` for that worker's lifetime + 72h until natural expiry, *or* until the run reaches terminal status (revocation). Blast radius bounded by run-id claim: only the compromised run's blobs/events/state are accessible. Not cross-run. `SESSION_SECRET` is NOT in the worker's env (Unit 3) so the worker cannot mint cross-run tokens.
|
||||
- **Interaction graph:** Worker subprocess no longer reads `~/.fabro/auth.json`. CLI user-facing commands (`fabro run`, `fabro ps`, `fabro auth`, etc.) unchanged — they still go through `connect_server_target` / `connect_server_with_settings`. SSE attach endpoints unchanged: worker is a producer, not a consumer; user-JWT-only auth on those routes preserved.
|
||||
- **Error propagation:** Worker token expiry mid-run → next server call returns 401, worker exits with a generic error, server marks run failed via existing pump-worker exit handling at `server.rs:4786`. No new error class.
|
||||
- **State lifecycle:** Server restart with HKDF-derived key: outstanding worker tokens remain valid up to natural expiry. Server restart with `SESSION_SECRET` rotated: outstanding workers fail at next call (acceptable; matches user-session invalidation). No revocation set.
|
||||
- **Event sink uniformity:** the worker wraps its `RunEventSink::fanout([Store(http), Callback])` (`runner.rs:100`) in `RunEventSink::Map { transform: stamp_system_worker, inner: fanout }`, so the stamp applies once *before* the fanout splits — both the HTTP backend and the local callback observe identical actor metadata. The shared `to_run_event_at` converter remains pure (passthrough). A test asserts per-sink uniformity directly.
|
||||
- **API surface parity:** `RunEvent.actor` shape unchanged (`ActorRef` already has `ActorKind::System`); worker-emitted events newly carry `system:worker` instead of `None`. Web UI audit (`apps/fabro-web/app/`) found ZERO references to `actor` or `author` today — nothing to break.
|
||||
- **Worker-process trust degradation:** A workflow stage that compromises the worker process (malicious shell, code injection) gains read access to `FABRO_WORKER_TOKEN` for the worker's lifetime + up to 72h until natural expiry. Blast radius bounded by run-id claim: only the compromised run's blobs/events/state are accessible. Not cross-run. `SESSION_SECRET` is NOT in the worker's env (`apply_worker_env` allowlist) so the worker cannot mint cross-run tokens.
|
||||
- **Integration coverage:** Unit 6 covers github-only-no-auth-store regression; existing CLI integration tests cover dev-token deployments.
|
||||
- **Unchanged invariants:** End-user auth (dev-token / github) unchanged. `RunAuthMethod` enum unchanged. `RunSpec.provenance` shape unchanged. Webhook auth unchanged. Artifact upload route's behavior from a worker's perspective unchanged (worker still authenticates and uploads — just via the unified token). `OpenAPI` / `fabro-api-client` (TypeScript) DTOs unchanged.
|
||||
- **Unchanged invariants:** End-user auth (dev-token / github) unchanged. `RunAuthMethod` enum unchanged. `RunSpec.provenance` shape unchanged. Webhook auth unchanged. Lifecycle/admin/SSE/list endpoints continue to require user auth — worker token explicitly rejected on all of them. `OpenAPI` / `fabro-api-client` (TypeScript) DTOs unchanged.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Worker process inherits `SESSION_SECRET` → can mint tokens for any run, defeating per-run binding | Unit 3 explicitly `env_remove`s `SESSION_SECRET` (and other server-only secrets) on every worker spawn. Verified by `worker_command_strips_server_secrets_from_worker_env` test. **Highest-impact mitigation in the plan.** |
|
||||
| Token leaks via `format!`/`Display`/`tracing` of `Credential::Worker` payload | `Credential::Worker` has redacted `Debug`, no `Display`. Compile-time guard test in Unit 2 asserts `!impl Display`. Code review must reject any `format!("... {token}")` pattern in worker code paths. |
|
||||
| Sentry / panic capture serializes the worker's env or backtrace locals | Sentry panic hook (`fabro-telemetry/src/panic.rs`) captures only panic message + stacktrace, not env or frame variables. Confirmed safe today. New panics in worker code with token in the formatted message would leak — code review responsibility, no structural fix. |
|
||||
| 72h token compromised mid-run, attacker uses it from any host on network | Run-id binding limits blast radius to one run. Server-side revocation set (Unit 2) cuts post-completion blast radius to zero **between server restarts** — see the restart re-enablement risk row below. If the threat model demands more (multi-tenant production), follow-up plan should add IP binding, proof-of-possession, or persisted revocation — out of scope here. |
|
||||
| `client.upload_stage_artifact_*` API forces per-call bearer parameter | Fallback in Unit 4: keep parameter, pass `client.credential().bearer_token()` from the worker side. |
|
||||
| `2026-04-22-003-refactor-lock-down-server-secrets-plan.md` lands first; its `apply_worker_env` allowlist must include `FABRO_WORKER_TOKEN` and EXCLUDE `SESSION_SECRET`/`FABRO_JWT_PRIVATE_KEY`/`GITHUB_APP_*` | Coordinate at land time. Both plans converge on env_clear + explicit re-injection. `FABRO_WORKER_TOKEN` replaces `FABRO_DEV_TOKEN` as the single re-injected secret. Verify the allowlist excludes server-only secrets — if not, this plan's `env_remove`s in `worker_command` are the safety net. |
|
||||
| Server restart mid-run with `SESSION_SECRET` rotated → outstanding workers die | Accepted. Same UX as user sessions. Operators who rotate `SESSION_SECRET` already accept session invalidation. |
|
||||
| `FABRO_AUTH_FILE` env var present in worker subprocess somehow re-introduces user OAuth | Worker explicitly does not call `AuthStore::default()`; the env var is only consulted by that constructor. Unit 4 removes the worker callsite. |
|
||||
| Worker child processes (sandbox, agent, devcontainer) inherit `FABRO_WORKER_TOKEN` | Chokepoint helper `apply_sandbox_env` (Unit 4) + explicit denylist in `LocalSandbox::should_filter_env_var` covers all worker-reachable spawn sites. Note: `clippy.toml` denies `std::process::Command::new` only — extend to `tokio::process::Command::new` (Unit 4) so the lint nudge actually catches the dominant async spawn pattern. |
|
||||
| `FABRO_WORKER_TOKEN` readable via `/proc/<pid>/environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers / namespaces. NOT a property of this design. |
|
||||
| Revocation set is in-memory; lost on server restart, but worker tokens survive restart by design (HKDF key persists) — creates post-restart re-enablement window for tokens belonging to terminated runs | **Known limitation, called out honestly.** A token captured pre-completion can be replayed for up to 72h after a routine deploy if the run completed before the deploy and the attacker waits out the restart. Mitigation options: (a) persist revocation set to a small KV (Redis or a SlateDB key), (b) at authorize time, look up the run's terminal status from the run store and reject if `claims.iat < run.terminal_at`. **Pick at implementation time** — see Open Questions. The plan explicitly does NOT claim revocation "cuts post-completion blast radius to zero" anymore. |
|
||||
| Same-run concurrent worker spawn (scheduler race, manual operator action) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. Verification step in Unit 3 audit must check `start_run` / `resume_run` / `unarchive_run` paths for race conditions. If not enforced today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope for this plan to fix the scheduler; in scope to flag the assumption. |
|
||||
| Rapid pause/resume cycles leave multiple valid tokens per run (each resume mints fresh, prior tokens not revoked) | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window. **Pick at implementation time:** (a) revoke prior tokens on every new spawn (requires tracking active tokens per run), (b) bind via `spawn_id` nonce that the server replaces on each spawn, (c) accept and document. See Open Questions. |
|
||||
| Web UI renders `ActorKind::System` poorly (assumed `User` only) | **Downgraded.** Audit of `apps/fabro-web/app/` found zero references to `actor`/`author` today — nothing to break. If future UI surfaces author display, that's additive work, not a regression. |
|
||||
| Worker process inherits `SESSION_SECRET` → can mint tokens for any run, defeating per-run binding | **Already structurally mitigated**: `apply_worker_env` at `spawn_env.rs:18` does `env_clear` + 8-name allowlist that excludes `SESSION_SECRET`. Existing `worker_allowlist_is_fail_closed` test asserts this. Unit 3 extends the test to also cover `FABRO_JWT_*` and `GITHUB_APP_*`. |
|
||||
| Token leaks via `format!`/`Display`/`tracing` of `Credential::Worker` payload | `Credential::Worker` has redacted `Debug`, no `Display`. Compile-time guard test in Unit 2 asserts `!impl Display`. Audit logging in Unit 2 logs `jti` only, never the token. |
|
||||
| Sentry / panic capture serializes the worker's env or backtrace locals | Sentry panic hook (`fabro-telemetry/src/panic.rs`) captures only panic message + stacktrace, not env or frame variables. Code review responsibility to keep token out of panic format strings. |
|
||||
| 72h token compromised mid-run, attacker uses it from any host on network | Run-id binding limits blast radius to one run. No revocation; rotating `SESSION_SECRET` is the only invalidation mechanism (also invalidates user sessions). Acceptable for current threat model. |
|
||||
| `FABRO_WORKER_TOKEN` readable via `/proc/<pid>/environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. |
|
||||
| Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. |
|
||||
| Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. |
|
||||
| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |
|
||||
| `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. |
|
||||
| Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. |
|
||||
| Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- `docs-internal/` — if any internal doc describes worker auth (search before landing), update to reflect: "worker → server auth uses a server-issued per-run JWT, independent of end-user auth method."
|
||||
- No external user-facing doc impact (no public API change; CLI args on `__run-worker` are internal-only, hidden via `#[command(hide = true)]`).
|
||||
|
||||
### Deploy story (atomic swap, no shim)
|
||||
### Deploy story (greenfield, atomic swap)
|
||||
|
||||
Verified: in-flight workers spawned pre-deploy continue working post-deploy via user-JWT fall-through (R7) — they still hold OAuth from `auth.json`, and `authorize_run_scoped` accepts user JWTs. The artifact-upload route (the only worker-touched route that today does NOT use bare `AuthenticatedService`) already implements the same fall-through pattern — `authorize_artifact_upload` at `lib/crates/fabro-server/src/server.rs:868` calls `authenticate_service_parts(parts)` after the upload-token check, so existing workers' user JWTs authenticate against artifact uploads post-deploy. Only newly-spawned workers post-deploy exercise the new contract; those start cleanly with `FABRO_WORKER_TOKEN`. Resumed runs re-spawn via `worker_command`, get a fresh token, no orphaning. **No drain or backwards-compat shim required.**
|
||||
No shipped deployments to preserve. Atomic swap:
|
||||
1. Deploy new binary; server restarts.
|
||||
2. New runs spawn workers with `FABRO_WORKER_TOKEN` in env; worker uses it via `Credential::Worker`.
|
||||
3. Old artifact-upload-token mechanism is gone from the codebase entirely.
|
||||
|
||||
**Pre-deploy checklist:**
|
||||
- Confirm `SESSION_SECRET` is set and stable across the restart (HKDF key derives from it).
|
||||
- Verify `apply_worker_env` (if `2026-04-22-003` landed) excludes `SESSION_SECRET` and other server-only secrets from the allowlist.
|
||||
- Grep all internal repos and `docs/api-reference/fabro-api.yaml` for `artifact-upload-token`, `artifact_upload_token`, `--artifact-upload-token`, `stage_artifacts:upload`. Document zero hits in the PR description before merging — the atomic-delete decision depends on no external consumer existing.
|
||||
- Baseline: record `count(runs where status=running)`.
|
||||
|
||||
**Post-deploy (within 5 min):**
|
||||
- `count(runs where status=running)` matches baseline ± natural completions.
|
||||
- grep server logs for `target=worker_auth` — expect zero hits (any hit means token-injection bug).
|
||||
- `WorkflowRunFailed` rate vs. 7-day baseline.
|
||||
- Spawn one new run end-to-end; confirm completion.
|
||||
- If any HITL-paused runs exist, resume one and confirm event emission.
|
||||
|
||||
**Rollback:** redeploy old binary. Workers spawned during the new-binary window fail at next call → marked failed → user re-runs. No data restoration needed; `FABRO_WORKER_TOKEN` is env-only, never persisted.
|
||||
No drain, no shim, no checklist beyond verifying `SESSION_SECRET` is set (HKDF key derives from it).
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Worker auth surface inventory: `lib/crates/fabro-cli/src/commands/run/runner.rs:55-125`
|
||||
- Artifact-upload-token model to generalize: `lib/crates/fabro-server/src/server.rs:291-293, 757-869`
|
||||
- Worker spawn site: `lib/crates/fabro-server/src/server.rs:3797-3851`
|
||||
- Worker auth surface inventory: `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127`
|
||||
- Artifact-upload-token model to replace: `lib/crates/fabro-server/src/server.rs:287-289, 752-854`
|
||||
- Worker spawn site: `lib/crates/fabro-server/src/server.rs:3701-3755`
|
||||
- Existing HKDF key derivation: `lib/crates/fabro-server/src/auth/keys.rs:41`
|
||||
- Existing worker env allowlist: `lib/crates/fabro-server/src/spawn_env.rs:18`
|
||||
- `Credential` variants: `lib/crates/fabro-client/src/credential.rs:6-30`
|
||||
- `ActorRef` / `ActorKind`: `lib/crates/fabro-types/src/run_event/mod.rs:29-81`
|
||||
- `RunProvenance`: `lib/crates/fabro-types/src/run.rs:34-49`
|
||||
- Stage-execution chokepoint: `lib/crates/fabro-sandbox/src/local.rs:221, 43-66`
|
||||
- Coordinated plans: `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md`, `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md`, `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md`
|
||||
- Origin of artifact-upload-token pattern: `docs/plans/2026-04-06-object-backed-artifact-uploads.md:42-45`
|
||||
- Worker subprocess history: `docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md`, `docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,538 @@
|
|||
---
|
||||
title: Move fabro pr commands server-side
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-04-23
|
||||
deepened: 2026-04-23
|
||||
---
|
||||
|
||||
# Move fabro pr commands server-side
|
||||
|
||||
## Overview
|
||||
|
||||
`fabro pr create|view|list|merge|close` currently run GitHub operations from the client using credentials loaded from the client's local vault. This breaks when the fabro server runs on a remote host, which is now the common deployment shape. Move the work to new fabro HTTP API endpoints so the CLI becomes thin presentation. The server already loads its own GitHub credentials for the in-run `pull_request` pipeline stage; the new endpoints reuse that path.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Audit of `lib/crates/fabro-cli/src/commands/pr/` found all five commands reach past the API boundary:
|
||||
|
||||
- `load_github_credentials_required` (`pr/mod.rs:35`) reads `base_ctx.machine_settings()` and the client-local vault for every subcommand, then calls `fabro_github::*` directly — fails on remote server because the client needs its own GitHub App / `GITHUB_TOKEN`.
|
||||
- `pr create` is the worst offender: it rebuilds server run state client-side via `rebuild_run_store` (`pr/create.rs:31`), runs `detect_repo_info(&cwd)` + `ensure_matching_repo_origin` (requires client to be in a matching git clone), generates the PR body with a client-side LLM call using client-loaded provider keys (`create.rs:101-127`), calls GitHub from the client, and never writes the resulting `PullRequestRecord` back to the server.
|
||||
- All five carry `#[allow(deprecated, reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side")]`.
|
||||
|
||||
The server already owns everything needed: `AppState::github_credentials(...)` loads creds; `run_manifest.rs:373-381` wires them into `PullRequestOptions`; `maybe_open_pull_request` in `fabro-workflow` contains the full create flow; `PullRequestCreated`/`PullRequestFailed` events already exist and already feed `RunProjection.pull_request`.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
### API Contract
|
||||
|
||||
- R1. `fabro pr {create,view,list,merge,close}` work correctly against a remote fabro server without the client holding any GitHub credentials.
|
||||
- R5. Existing CLI surface is preserved: same command names, flags (`--force`, `--model`, `--all`, `--method`, `--json`), same stdout/stderr shape (URL on success, tables for list, etc.).
|
||||
|
||||
### Client-Side Cleanup
|
||||
|
||||
- R2. The CLI crate stops importing client-loaded GitHub creds for PR ops. `load_github_credentials_required` is deleted. `boundary-exempt(pr-api)` annotations are removed.
|
||||
- R3. `pr create` stops rebuilding run state client-side (`rebuild_run_store`), stops reading the client's cwd (`detect_repo_info`/`ensure_matching_repo_origin`), and stops picking the LLM model from client-side `configured_providers_from_process_env`.
|
||||
- R6. `rebuild_run_store` keeps working for its other callers (`run fork`, `run rewind`) — only `pr/create.rs` stops calling it. The helper itself is not deleted in this plan.
|
||||
|
||||
### State Correctness
|
||||
|
||||
- R4. The persisted `PullRequestRecord` on the server matches what `maybe_open_pull_request` returns, i.e. `state.pull_request` is populated after `fabro pr create` the same way it is after the in-run PR stage. The existing `PullRequestCreated` event is the mechanism.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
This refactor moves GitHub operations from client to server. It intentionally does not expand into broader run-model cleanup, lifecycle bookkeeping, or multi-host support.
|
||||
|
||||
**The plan must be read in light of these invariants:**
|
||||
- **Runs without git metadata remain valid.** `RunSpec.repo_origin_url` and `RunSpec.base_branch` stay `Option<String>`. PR operations simply don't apply to runs that lack the required git metadata — the endpoints return explicit 400s for those runs.
|
||||
- **PR operations are available only for runs that have sufficient git metadata** (repo origin, base branch, run branch, non-empty diff on create).
|
||||
- **github.com is the only supported host.** GitHub Enterprise is not in scope.
|
||||
- **Fabro stores PR creation, not authoritative PR lifecycle state.** After `pr create`, the `PullRequestRecord` is persisted via `PullRequestCreated`. After `pr merge` or `pr close`, nothing is written to run state — GitHub is the source of truth for current PR status.
|
||||
- **`pr view` and `pr list` depend on live GitHub availability** to surface current status; they are not offline operations.
|
||||
|
||||
**In scope:**
|
||||
- Four new run-scoped fabro-api endpoints (view, merge, close, create) and their progenitor-regenerated clients.
|
||||
- Five rewritten CLI commands (`pr list` is CLI-only composition).
|
||||
- Reuse of the existing `PullRequestCreated` event to persist the record on `pr create` — no new event variants.
|
||||
- Host check restricting outbound GitHub calls to github.com.
|
||||
- Deletion of `load_github_credentials_required` and related client-side PR helpers.
|
||||
- Updates to CLI integration tests.
|
||||
|
||||
**Out of scope:**
|
||||
- Moving `run fork` and `run rewind` server-side (tracked separately — still uses `rebuild_run_store`).
|
||||
- Deleting `rebuild_run_store` (still used by fork/rewind).
|
||||
- UI changes in `apps/fabro-web`.
|
||||
- Changing `PullRequestRecord`'s existing fields.
|
||||
- New `PullRequestMerged` / `PullRequestClosed` event variants, reducer arms, or durable merged/closed state.
|
||||
- GitHub Enterprise / multi-host routing.
|
||||
- New server-side audit events or audit-event infrastructure.
|
||||
- `--draft` / `--no-draft` CLI flag, `model_used` in the create response, or any other CLI contract change that isn't needed to complete the move.
|
||||
- LLM `body.model` allowlist, per-subject cost ceiling, or any AI-safety infrastructure.
|
||||
- A global `GET /pull_requests` endpoint — `pr list` is CLI-side composition.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- Server handler template — run-scoped POST with JSON body: `archive_run` (`lib/crates/fabro-server/src/server.rs:6448`), `cancel_run` (`server.rs:6085`), `append_run_event` (`server.rs:4990`). All use `AuthorizeRunScoped` or `Path(id)` + `parse_run_id_path`, `state.store.open_run(&id)`, and return `Json(...)`.
|
||||
- Server-side GitHub creds helper: `AppState::github_credentials(&self, settings)` at `lib/crates/fabro-server/src/server.rs:695`. Already used by `run_manifest.rs:373-381` when building `PullRequestOptions` for the in-run pipeline stage. This is the canonical path — the new handlers call the same helper.
|
||||
- OpenAPI path template: `appendRunEvent` at `docs/api-reference/fabro-api.yaml:940-971` — operationId in camelCase, `$ref: "#/components/parameters/RunId"` for the run ID, body and response schemas as `$ref` components.
|
||||
- Existing OpenAPI PR type: `RunPullRequest` (`fabro-api.yaml:4227-4247`) is a presentation summary (`number, additions, deletions, comments, checks`), not the full record. It stays as-is. This plan adds two new distinct schemas: `PullRequestRecord` (the full persisted record, aligned with `lib/crates/fabro-types/src/pull_request.rs:5` and reused via `fabro-api/build.rs` `with_replacement(...)` per `CLAUDE.md` API type ownership policy) and `PullRequestDetail` (stored record + live GitHub fields, response of `pr view`). `pr list` has no HTTP response schema — CLI composes the list from `list_runs` + per-run view calls. Neither new schema replaces `RunPullRequest`.
|
||||
- Server-secrets handling: follow `docs-internal/server-secrets-strategy.md`. The new handlers must not surface `GITHUB_APP_PRIVATE_KEY` PEM bytes or `GITHUB_TOKEN` values in HTTP response bodies, error messages, or tracing spans. Upstream GitHub errors pass through redaction before surfacing. Use `fabro_util` redaction helpers, never raw string interpolation of credentials.
|
||||
- GitHub API call surface: `fabro_github::{create_pull_request, get_pull_request, merge_pull_request, close_pull_request, branch_exists, ssh_url_to_https, parse_github_owner_repo, github_api_base_url}`. All are already callable from server code; the worker crate uses them today.
|
||||
- Core create-flow reuse: `fabro_workflow::pull_request::maybe_open_pull_request` at `lib/crates/fabro-workflow/src/pipeline/pull_request.rs:405`. Takes creds, origin URL, branches, goal, diff, model, `draft`, `AutoMergeOptions`, `run_store`, `conclusion`. Returns `Option<PullRequestRecord>` (None on empty diff). The server-side handler calls this directly — no new GitHub-call code.
|
||||
- Event emission for create: `PullRequestCreated` variant at `lib/crates/fabro-workflow/src/event.rs:498-507`, emission site at `pipeline/pull_request.rs:545`. The new `POST /runs/{id}/pull_request` handler emits the same event via `state.store.open_run(&id).append_event(...)` (see `server.rs:5018-5025` for the pattern).
|
||||
- Client method template: `Client::cancel_run` (`lib/crates/fabro-client/src/client.rs:717-723`) for simple POSTs; `Client::create_secret` (`client.rs:525-535`) for POST-with-body-returning-JSON.
|
||||
- Precedent: `refactor(dump)` in commit `1481ecf2a` — same-shape boundary cleanup on a smaller surface.
|
||||
- Existing CLI integration tests: `lib/crates/fabro-cli/tests/it/cmd/pr_{create,view,list,merge,close}.rs`. These currently exercise the client-side-GitHub path; they're rewritten to exercise the server path.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- `docs/solutions/` is empty. No prior PR-boundary learnings.
|
||||
- `CLAUDE.md` API type ownership: when an OpenAPI schema has the same semantics as an existing Rust type, reuse the Rust type via `with_replacement(...)` in `fabro-api/build.rs` and add a `fabro-api` test proving JSON parity. `PullRequestRecord` qualifies.
|
||||
- `CLAUDE.md` testing guidance: CLI integration tests should create state through public commands; writing run internals is disallowed. The existing `pr_*.rs` tests already follow this.
|
||||
|
||||
### External References
|
||||
|
||||
None. All grounding is in-repo.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Four run-scoped endpoints; no global list.** Routes: `GET /api/v1/runs/{id}/pull_request` (view), `POST /api/v1/runs/{id}/pull_request` (create), `POST /api/v1/runs/{id}/pull_request/merge`, `POST /api/v1/runs/{id}/pull_request/close`. `pr list` has no endpoint — CLI composes it from existing operations. *Rationale:* run-scoped auth stays simple (`AuthorizeRunScoped`); avoids inventing a new auth model for a global endpoint.
|
||||
|
||||
- **Reuse `PullRequestRecord` via `with_replacement`.** Add the schema to OpenAPI for contract visibility, but map the progenitor-generated type back to `fabro_types::PullRequestRecord` so Rust server, Rust client, and CLI all speak one type. *Rationale:* CLAUDE.md policy; `PullRequestRecord` already has the right shape and is already in `RunProjection`.
|
||||
|
||||
- **Only `PullRequestCreated` is persisted.** `pr merge` and `pr close` call GitHub and return success responses; they do not append events, do not update `state.pull_request`, do not add new event variants. *Rationale:* GitHub is the source of truth for current PR state. Fabro records that *a PR was opened* (for later reference by view/list); it does not track the PR's full lifecycle. This keeps the refactor focused and avoids growing the event schema for state Fabro doesn't need to own.
|
||||
|
||||
- **`RunSpec.repo_origin_url` and `base_branch` stay `Option<String>`.** Runs without git metadata remain valid. PR operations on such runs return explicit 400s naming the missing field. *Rationale:* out of scope to change run-model invariants; the refactor is about moving GitHub ops, not tightening run creation.
|
||||
|
||||
- **Server does its own branch/diff/conclusion validation.** What `pr/create.rs` validates client-side (`conclusion.status`, `run_branch` present, `final_patch` non-empty, branch exists on GitHub) all moves to the server handler, reading from `state.store.open_run(&id)` directly. *Rationale:* server has authoritative state; avoids race conditions between client rebuild and server truth.
|
||||
|
||||
- **CLI request bodies preserve existing flags.** `pr create` body: `{ force: bool, model: Option<String> }` — same shape as today's CLI args. No `--draft` flag added (current behavior is draft PR creation; keep it). *Rationale:* keep the CLI contract stable; only change what the refactor requires.
|
||||
|
||||
- **Live GitHub state stays server-side on `view`.** `GET /runs/{id}/pull_request` requires a stored `PullRequestRecord`, calls GitHub live, returns `PullRequestDetail` (stored record + live fields: `state`, `draft`, `merged`, `title`, `html_url`, `head`/`base` ref, `user`, `additions`, `deletions`, `changed_files`, `body`). `merged` is distinct from `state` — GitHub's API returns `state: "open" | "closed"` + a separate `merged: bool`; Unit 4's list classifier depends on this separation. *Rationale:* client stays credential-free for display; GitHub is authoritative for current PR status.
|
||||
|
||||
- **`pr list` is CLI-side composition with cheap skip.** CLI calls `list_runs`, filters client-side to runs whose existing run state already contains a stored `pull_request` record (via `get_run_state` or an equivalent lightweight probe), then calls `get_run_pull_request` only for those runs via `buffer_unordered(10)`. Runs without a stored record are skipped without any GitHub probe. *Rationale:* the cheap path — most runs don't have PRs; don't pay for GitHub calls on those.
|
||||
|
||||
- **No `rebuild_run_store` from the server handlers.** Server reads `RunProjection` directly via `state.store.open_run(&id)` / in-memory live state. *Rationale:* the reason `rebuild_run_store` exists is that the CLI couldn't touch server state — the server obviously can.
|
||||
|
||||
- **Error model across the four endpoints:**
|
||||
- `404 no_stored_record` — run has no stored `PullRequestRecord`.
|
||||
- `409 conflict` — `pr create` called when a stored record already exists.
|
||||
- `400 bad_request` — run precondition failure: missing `repo_origin_url`, missing `base_branch`, missing `run_branch`, empty diff, unsupported host, or similar.
|
||||
- `502 github_not_found` — stored record exists but GitHub no longer has that PR.
|
||||
- `503 integration_unavailable` — server GitHub creds missing or disabled. Generic external body; detailed diagnostic in log only. Authorizer runs first.
|
||||
|
||||
- **Accept the TOCTOU race on concurrent `pr create`.** Two simultaneous creates can both pass the None-check and both call GitHub before either emits, producing duplicate GitHub PRs. *Rationale:* narrow collision window; recoverable symptom (user closes the duplicate); mutex/intent-event alternatives are disproportionate. Documented in Risks.
|
||||
|
||||
- **Host check: github.com only.** Before any outbound GitHub call, verify the origin host is `github.com`. Reject with 400 `unsupported_host` otherwise. The source of the host differs by endpoint: `pr create` parses it from `run_spec.repo_origin_url` (the record doesn't exist yet); `pr view` / `pr merge` / `pr close` parse it from `record.html_url` (always present on any stored record, no cross-reference to run spec needed). *Rationale:* simpler than an allowlist, aligns with current scope; defense in depth against SSRF. GitHub Enterprise support is not in scope. Two-source check is deliberate — each endpoint has exactly one authoritative value available at the time of check.
|
||||
|
||||
- **Capture the GitHub API base URL once, at `AppState` construction.** Today `fabro_github::github_api_base_url()` reads `GITHUB_BASE_URL` from env on every call (`fabro-github/src/lib.rs:8`), and each outbound request template is `{base_url}/repos/{owner}/{repo}/...` (`fabro-github/src/lib.rs:969, 1039, 1100`). The origin host check on `repo_origin_url` / `html_url` therefore only validates our *intent* to talk to github.com — the actual authenticated HTTP destination comes from the env read, which can drift between requests. Fix: read `GITHUB_BASE_URL` (or its default `https://api.github.com`) once during `AppState` construction, store it on the state struct, and have every new PR handler pass **that** value to the outbound GitHub functions. This means two kinds of changes: (1) direct calls like `branch_exists`, `get_pull_request`, `merge_pull_request`, `close_pull_request` already take `base_url` and simply get the captured value instead of a fresh env read; (2) the shared helper `fabro_workflow::pull_request::maybe_open_pull_request` at `pipeline/pull_request.rs:405` grows a `base_url: &str` parameter so it stops hardcoding `github_api_base_url()` internally at lines 440 and 457 — handlers that go through this helper (Unit 5) thread the captured value through. Runtime env mutations after server start have no effect. *Rationale:* closes the SSRF-via-env-mutation gap that the per-request host check alone doesn't cover; test infrastructure keeps its override path (tests construct `AppState` with a twin base URL at startup). The existing in-run pipeline caller of `maybe_open_pull_request` passes `&github_api_base_url()` explicitly to preserve today's behavior — fully hardening that path is out of scope for this plan but now trivially possible.
|
||||
|
||||
- **Stage-less event envelope on HTTP-originated `PullRequestCreated`; audit consumers.** The HTTP handler emits `PullRequestCreated` without a stage scope (envelope lacks `stage_id`/`node_id`). *Rationale:* honest — it wasn't emitted by a pipeline stage. Before landing Unit 5 (the only unit that emits), audit every consumer of the event that groups or filters by `stage_id` (run_progress UI, SSE replay, `RunProjection` hydration, analytics) and add integration tests proving each tolerates `stage_id = None`.
|
||||
|
||||
- **Sequencing: simplest first, `create` last.** Order is view → merge → close → list (CLI-only) → create → cleanup. *Rationale:* view establishes the pattern; merge and close are tiny endpoints with no event work; list is pure CLI; create carries the most logic and is done last when the pattern is proven.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- *Where does the server get GitHub credentials?* → `AppState::github_credentials(settings)` at `server.rs:695`. Same path `run_manifest.rs:373` uses today.
|
||||
- *Where does the server get LLM credentials for PR body generation?* → `fabro_auth::configured_providers_from_process_env(state.vault.as_ref())`, same helper `operations/start.rs:322` uses.
|
||||
- *Does `pr merge`/`pr close` need new events or durable state updates?* → **No.** GitHub is the source of truth for current PR state. Fabro records PR creation only. `view` / `list` re-read from GitHub on every call.
|
||||
- *Does `pr create` need `ensure_matching_repo_origin`?* → No. Server uses `run_spec.repo_origin_url` directly — there's no cwd on the server.
|
||||
- *Should there be a global `GET /pull_requests` endpoint?* → No. CLI composes `pr list` by filtering `list_runs` to runs with a stored `pull_request` record, then calling `get_run_pull_request` for each via `buffer_unordered(10)`.
|
||||
- *What happens if `state.pull_request` already exists when `POST /runs/{id}/pull_request` is called?* → Return 409 Conflict with the existing record.
|
||||
- *What LLM model does `create` default to on the server?* → `Catalog::builtin().default_for_configured(&configured)` using the server's configured providers.
|
||||
- *What about concurrent `pr create` on the same run?* → Race accepted. Two simultaneous calls may produce duplicate GitHub PRs. Documented in Risks.
|
||||
- *What authorizer on the mutating endpoints?* → `AuthorizeRunScoped` (same as existing run-scoped mutators like `archive_run`, `cancel_run`). Blast-radius expansion noted in Risks.
|
||||
- *What about SSRF via `repo_origin_url`?* → Host check: github.com only. Reject with 400 on any other host.
|
||||
- *What about GitHub Enterprise?* → Not supported in this plan. github.com only.
|
||||
- *HTTP status for missing GitHub creds?* → 503 Service Unavailable, generic external body (`integration_unavailable`); detailed diagnostic only in logs; authorizer runs first.
|
||||
- *HTTP status for the two "not found" cases on view?* → 404 `no_stored_record` when `state.pull_request` is None; 502 `github_not_found` when stored record exists but GitHub returns 404.
|
||||
- *Should `RunSpec.repo_origin_url` and `base_branch` be made required?* → **No.** They stay `Option<String>`. Runs without git metadata remain valid runs. PR operations on such runs return 400 naming the missing field.
|
||||
- *Does this plan add audit-event machinery?* → No. Per-user GitHub attribution is lost (server acts under its App identity); noted as an accepted tradeoff in Risks. Revisit in a follow-up if needed.
|
||||
- *Does this plan add a `--draft` flag or a `model_used` response field?* → No. CLI contract stays as close to current as possible.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact progenitor method names — they follow `operationId` but sometimes snake_case differs (e.g., `createPullRequestForRun` vs `create_pull_request_for_run`). Verified at build time.
|
||||
- Whether `pr list`'s cheap-skip should probe `state.pull_request` via `get_run_state` per run (one extra round trip per candidate) or via a richer `list_runs` response that already carries that field. Decide during implementation based on whether `list_runs`'s current response shape already includes it.
|
||||
- Whether to unbound the CLI-side concurrency cap in a follow-up if 10 turns out to be too conservative for typical `pr list` sizes.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||
|
||||
### Request flow (create, representative of the pattern)
|
||||
|
||||
```
|
||||
CLI (fabro pr create RUN --force --model M)
|
||||
│
|
||||
├─ parse args, build request body { force, model }
|
||||
│
|
||||
└─ HTTP POST /api/v1/runs/{id}/pull_request
|
||||
│
|
||||
▼
|
||||
Server handler create_run_pull_request
|
||||
│
|
||||
├─ resolve run id, load RunProjection
|
||||
├─ validate repo_origin_url, base_branch, run_branch all present (else 400)
|
||||
├─ validate host(run_spec.repo_origin_url) == github.com (else 400)
|
||||
├─ validate conclusion.status (or --force)
|
||||
├─ validate final_patch non-empty after trim (else 400)
|
||||
├─ validate state.pull_request is None (else 409)
|
||||
├─ load GitHub creds via AppState::github_credentials (else 503)
|
||||
├─ load LLM providers via configured_providers_from_process_env
|
||||
├─ pick default model if body.model is None
|
||||
├─ call maybe_open_pull_request(...) (draft=true, matching current CLI)
|
||||
├─ append PullRequestCreated event (stage-less envelope) via run_store
|
||||
└─ return Json(PullRequestRecord)
|
||||
│
|
||||
▼
|
||||
CLI: print record.html_url (or --json)
|
||||
```
|
||||
|
||||
### Endpoint summary
|
||||
|
||||
| Verb | Path | CLI | Request body | Response |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/api/v1/runs/{id}/pull_request` | `pr create` | `{ force: bool, model: Option<String> }` | `PullRequestRecord` (400 on missing git metadata or empty diff; 409 if already created; 503 if creds missing). Emits `PullRequestCreated`. |
|
||||
| GET | `/api/v1/runs/{id}/pull_request` | `pr view` | — | `PullRequestDetail` (stored record + live GitHub fields); 404 `no_stored_record` if no record; 502 `github_not_found` if GitHub can't find it; 503 if creds missing. |
|
||||
| POST | `/api/v1/runs/{id}/pull_request/merge` | `pr merge` | `{ method: MergeMethod }` | `{ number, html_url, method }`. No event emitted. No state update. |
|
||||
| POST | `/api/v1/runs/{id}/pull_request/close` | `pr close` | — | `{ number, html_url }`. No event emitted. No state update. |
|
||||
|
||||
`pr list` has no server endpoint — CLI filters `list_runs` to runs with a stored `pull_request`, then calls `GET /runs/{id}/pull_request` per matching run via `futures::StreamExt::buffer_unordered(10)`.
|
||||
|
||||
**CLI contract change note (intentional):** `pr view` and `pr list` responses pass through richer `PullRequestDetail` fields than the old CLI emitted (e.g., structured `head`/`base`, numeric `additions`/`deletions`). `--json` output therefore becomes richer than before. Human-readable output (table for list, formatted view) stays as close to current behavior as practical; any deliberate contract change is called out in the relevant unit.
|
||||
|
||||
### What the CLI files look like after this plan
|
||||
|
||||
Each `pr/*.rs` file collapses to: parse args → call `client.<method>()` → print. No `fabro_github::` import. No `load_github_credentials_required`. No `rebuild_run_store`. No `detect_repo_info`. Comparable shape to the post-refactor `dump.rs`.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Add `pr view` endpoint + rewrite CLI**
|
||||
|
||||
**Goal:** Simplest endpoint establishes the pattern. Server-side GitHub call replaces client-side GitHub call.
|
||||
|
||||
**Requirements:** R1, R2, R5
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api-reference/fabro-api.yaml` — add `GET /api/v1/runs/{id}/pull_request` endpoint + schemas:
|
||||
- `PullRequestRecord` — the full record (reused by the create response in Unit 5; establishing it here).
|
||||
- `PullRequestDetail` — view response = stored record + live GitHub fields, including a `merged: bool` field distinct from `state`.
|
||||
- Modify: `lib/crates/fabro-github/src/lib.rs` — extend the in-crate `PullRequestDetail` struct to capture GitHub's `merged: bool` and `merged_at: Option<String>` fields (currently missing at line 20, which is why today's CLI can't actually surface "merged" state — the `Color::Magenta` branch in `pr/list.rs:140` is dead code). Update the GitHub API deserializer accordingly.
|
||||
- Modify: `lib/crates/fabro-api/build.rs` — add `with_replacement("PullRequestRecord", "fabro_types::PullRequestRecord")`.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` — (1) add a `github_api_base_url: String` field on `AppState` populated at construction by calling `fabro_github::github_api_base_url()` **once** (so a later env mutation can't redirect traffic); (2) add handler `get_run_pull_request`, register route in `build_router()`. The new handler passes `state.github_api_base_url.as_str()` to `fabro_github::get_pull_request` instead of calling `github_api_base_url()` at request time. This capture field is reused by Units 2, 3, 5.
|
||||
- Modify: `lib/crates/fabro-client/src/client.rs` — add `Client::get_run_pull_request(&self, run_id: &RunId) -> Result<PullRequestDetail>`.
|
||||
- Rewrite: `lib/crates/fabro-cli/src/commands/pr/view.rs` — call the new client method; remove `load_github_credentials_required` call + `fabro_github::get_pull_request` call.
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_view.rs` — update expectations; real server path; assert `merged` field round-trips correctly.
|
||||
- Test: `lib/crates/fabro-api/tests/` — JSON round-trip parity test for `PullRequestRecord`. Use a shared fixture asserting that (a) deserializing it as the progenitor-generated schema and (b) deserializing it as `fabro_types::PullRequestRecord` both succeed and re-serialize to byte-identical JSON. This is a wire-format test, not a type-identity check. Cover every inclusion site as later units add them (create response in Unit 5, inside `PullRequestDetail` here).
|
||||
|
||||
**Approach:**
|
||||
- Handler loads `RunProjection` via `state.store.open_run(&id)`, reads stored `PullRequestRecord`, calls `fabro_github::get_pull_request` with server creds, composes `PullRequestDetail` (record + live fields: state, draft, **merged**, title, html_url, head/base ref, user, additions, deletions, changed_files, body). `merged` is independent of `state` — GitHub's API returns `state: "open" | "closed"` plus a separate `merged: bool`; a merged PR has `state: "closed"` + `merged: true`, a closed-without-merging PR has `state: "closed"` + `merged: false`.
|
||||
- Error shapes: return 404 with body `{"error": "no_stored_record", "message": "..."}` when `state.pull_request` is None; return 502 with body `{"error": "github_not_found", "message": "..."}` when stored record exists but GitHub returns 404. Return 503 with generic external body `{"error": "integration_unavailable"}` when server GitHub creds are missing (detailed diagnostic in log only; authorizer runs first).
|
||||
- Host check: parse the host from `record.html_url` (always present, always the rendered GitHub host) and verify it equals `github.com`. Reject with 400 `unsupported_host` otherwise. Do **not** rely on a separate `origin_host` field on `PullRequestRecord` — no such field exists; `html_url` is the source of truth for existing records. (GitHub Enterprise is not in scope for this plan.)
|
||||
- Follow `archive_run` handler pattern for path/auth/response shape.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `archive_run` at `server.rs:6448` — run-scoped action handler shape.
|
||||
- `appendRunEvent` OpenAPI path at `fabro-api.yaml:940-971`.
|
||||
- `Client::cancel_run` at `client.rs:717-723`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (open): stored record + GitHub returns `state: "open"`, `merged: false` → `PullRequestDetail.state == "open"`, `merged == false`; CLI prints `#N title / State: open / URL / Branch / Author / Changes / body`.
|
||||
- Happy path (merged): GitHub returns `state: "closed"`, `merged: true` → `PullRequestDetail.merged == true`; CLI prints `State: merged` (distinct from closed).
|
||||
- Happy path (closed-not-merged): GitHub returns `state: "closed"`, `merged: false` → CLI prints `State: closed`.
|
||||
- Happy path (draft): GitHub returns `draft: true`, `merged: false` → CLI prints `State: draft`.
|
||||
- Error path: run without `state.pull_request` → 404 with `{"error": "no_stored_record"}`; CLI prints "No pull request found for this run. Create one first with: fabro pr create …".
|
||||
- Error path: stored record exists, GitHub API returns 404 → 502 with `{"error": "github_not_found"}`; CLI prints a distinct message ("PR #N was deleted on GitHub").
|
||||
- Error path: server GitHub creds missing → 503 with `{"error": "integration_unavailable"}`; CLI prints "GitHub integration unavailable on server".
|
||||
- Error path: `record.html_url` parses to a host other than `github.com` → 400 `unsupported_host`; CLI prints clear rejection.
|
||||
- Integration: request reaches server over HTTP (not in-process), run is resolved by prefix, live fields match GitHub fixture (including `merged`).
|
||||
- **SSRF defense: outbound URL comes from captured startup value, not per-request env lookup.** Test without mutating process env (per `docs-internal/server-secrets-strategy.md:12` — `std::env::set_var` / `remove_var` are banned workspace-wide, tests not exempt). Two acceptable shapes:
|
||||
- *Preferred:* harness-level `AppState` construction test that injects `github_api_base_url: "http://twin.local/api".to_string()` directly, makes a `pr view` request, and asserts via a request-capturing HTTP mock that the outbound call landed at `http://twin.local/api/...` — not at whatever `std::env::var("GITHUB_BASE_URL")` would return. No env mutation involved.
|
||||
- *Alternative:* subprocess test — spawn one fabro server with `GITHUB_BASE_URL=http://twin-a.local` in the spawn env, run a request, kill; spawn another with `GITHUB_BASE_URL=http://twin-b.local`, run, kill. Each subprocess has its own immutable env. Slower than the in-process variant but no in-process env mutation.
|
||||
- The in-process-env-mutation variant (set `GITHUB_BASE_URL` from the test body after server start) is **not acceptable** — it violates the workspace rule and will fail clippy.
|
||||
- JSON mode: `--json` returns the full `PullRequestDetail` as structured JSON matching OpenAPI schema, including `merged`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-server <view-handler-test>` passes.
|
||||
- `cargo nextest run -p fabro-cli --test it 'cmd::pr_view'` passes end-to-end.
|
||||
- CLI no longer imports `fabro_github` or `super::load_github_credentials_required`.
|
||||
- All three error paths covered by dedicated tests with distinct HTTP status assertions.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Add `pr merge` endpoint + rewrite CLI**
|
||||
|
||||
**Goal:** Same pattern as view, mutating. GitHub call only — no events, no state updates.
|
||||
|
||||
**Requirements:** R1, R2, R5.
|
||||
|
||||
**Dependencies:** Unit 1 (establishes `PullRequestRecord` schema in OpenAPI).
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api-reference/fabro-api.yaml` — add `POST /api/v1/runs/{id}/pull_request/merge` + request body + response + `MergeMethod` enum.
|
||||
- Modify: `lib/crates/fabro-github/...` — add strum derives to `AutoMergeMethod` (`Display`, `EnumString`, `IntoStaticStr`, `#[strum(serialize_all = "snake_case")]`) per CLAUDE.md strum policy, keeping the variants identical to today's enum. Alias as `MergeMethod` for the OpenAPI surface if name clarity warrants.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` — add handler `merge_run_pull_request`, wire route.
|
||||
- Modify: `lib/crates/fabro-client/src/client.rs` — add `Client::merge_run_pull_request(run_id, method) -> Result<MergeResponse>`.
|
||||
- Rewrite: `lib/crates/fabro-cli/src/commands/pr/merge.rs` — call new client method.
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs`.
|
||||
|
||||
**Approach:**
|
||||
- Handler resolves run id, loads stored `PullRequestRecord` (404 `no_stored_record` if missing), parses the host from `record.html_url` and verifies it equals `github.com` (400 `unsupported_host` otherwise), loads GitHub creds (503 `integration_unavailable` if missing), calls `fabro_github::merge_pull_request` with the requested method, passing `state.github_api_base_url.as_str()` (captured at startup by Unit 1) as the base URL. Returns `{ number, html_url, method }`.
|
||||
- **No event emitted. No change to `state.pull_request`.** GitHub is the source of truth for merged state; a subsequent `pr view` re-reads from GitHub.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `archive_run` for mutating run-scoped action shape.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: valid method `Squash` → GitHub merge called server-side; CLI prints `Merged #N (URL)`.
|
||||
- Edge case: default `--method squash` fills the request body client-side (clap default), server accepts.
|
||||
- Error path: invalid method `--method foo` → clap rejects before request.
|
||||
- Error path: run without stored PR → 404 `no_stored_record`.
|
||||
- Error path: server GitHub creds missing → 503 `integration_unavailable`.
|
||||
- Error path: `record.html_url` parses to a host other than `github.com` → 400 `unsupported_host`.
|
||||
- JSON mode: `--json` returns `{ number, html_url, method }`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it 'cmd::pr_merge'` passes.
|
||||
- No new events added to `fabro_workflow::event::Event`.
|
||||
- CLI no longer uses `fabro_github::merge_pull_request`.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: Add `pr close` endpoint + rewrite CLI**
|
||||
|
||||
**Goal:** Mirrors Unit 2's shape. GitHub call only — no events, no state updates.
|
||||
|
||||
**Requirements:** R1, R2, R5.
|
||||
|
||||
**Dependencies:** Unit 1.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api-reference/fabro-api.yaml` — add `POST /api/v1/runs/{id}/pull_request/close` + response schema.
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` — add handler `close_run_pull_request`.
|
||||
- Modify: `lib/crates/fabro-client/src/client.rs` — add `Client::close_run_pull_request(run_id)`.
|
||||
- Rewrite: `lib/crates/fabro-cli/src/commands/pr/close.rs`.
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_close.rs`.
|
||||
|
||||
**Approach:**
|
||||
- Handler resolves run id, loads stored `PullRequestRecord` (404 `no_stored_record` if missing), parses the host from `record.html_url` and verifies it equals `github.com` (400 `unsupported_host` otherwise), loads GitHub creds (503 if missing), calls `fabro_github::close_pull_request` with `state.github_api_base_url.as_str()`. Returns `{ number, html_url }`.
|
||||
- **No event emitted. No change to `state.pull_request`.** GitHub is the source of truth for closed state.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Unit 2 shape minus the method body.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: close existing PR → GitHub close called; CLI prints `Closed #N (URL)`.
|
||||
- Error path: run without stored PR → 404 `no_stored_record`.
|
||||
- Error path: PR already closed upstream → GitHub returns error; server surfaces it as 502; CLI exits non-zero.
|
||||
- Error path: server GitHub creds missing → 503.
|
||||
- Error path: `record.html_url` parses to a host other than `github.com` → 400 `unsupported_host`.
|
||||
- JSON mode: `--json` returns `{ number, html_url }`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it 'cmd::pr_close'` passes.
|
||||
- No new events added to `fabro_workflow::event::Event`.
|
||||
- CLI no longer uses `fabro_github::close_pull_request`.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 4: Rewrite `pr list` CLI to compose from existing operations**
|
||||
|
||||
**Goal:** `pr list` becomes pure CLI composition — no new server endpoint. Takes the cheap path: only runs that already have a stored `pull_request` record trigger a live GitHub call. Runs without a stored record are skipped before any GitHub probe.
|
||||
|
||||
**Requirements:** R1, R2, R5.
|
||||
|
||||
**Dependencies:** Unit 1 (per-run view endpoint returning `PullRequestDetail`). No server work in this unit.
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `lib/crates/fabro-cli/src/commands/pr/list.rs` — fetch runs via the existing `list_runs` client method, filter locally to runs whose run state already indicates a stored `pull_request` record (probe via existing `get_run_state` or equivalent lightweight call), then `futures::stream::iter(candidates).map(|r| client.get_run_pull_request(&r.run_id)).buffer_unordered(10)` to fetch `PullRequestDetail` for each. Apply the existing `--all` vs open/draft/unknown filter client-side. Table/JSON rendering stays.
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_list.rs` — existing tests adjust to the new flow.
|
||||
|
||||
**Approach:**
|
||||
- The filter step is the key: runs without a stored `pull_request` record skip the GitHub call entirely. Only candidates (runs with a stored record) hit GitHub. This keeps GitHub traffic proportional to PR count, not run count.
|
||||
- The CLI iterates at most 10 candidates in flight. For typical deployments (a minority of runs have PRs), this gives a good throughput / fairness tradeoff.
|
||||
- Each returned `PullRequestDetail` is classified locally into one of `open`, `draft`, `merged`, `closed`, or `unknown` using both `state` and `merged` fields:
|
||||
- `merged == true` → `merged` (regardless of `state`, though GitHub always has `state: "closed"` in this case).
|
||||
- `state == "open"` + `draft == true` → `draft`.
|
||||
- `state == "open"` + `draft == false` → `open`.
|
||||
- `state == "closed"` + `merged == false` → `closed`.
|
||||
- Anything else, or a 502 `github_not_found` from the view endpoint → `unknown`.
|
||||
- Default filter: `open` + `draft` + `unknown`. `--all`: adds `merged` + `closed`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `pr/list.rs:48-88` today — the existing join_all shape, swapped for `buffer_unordered(10)` and preceded by the stored-record filter.
|
||||
- Existing `ServerSummaryLookup` usage for pulling runs + state — adapted to just the filter step.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: multiple runs with stored PRs across all five states → default filter shows open + draft + unknown; `--all` adds merged + closed. Classification matches the rules above (uses `merged: bool`, not a magic "merged" value in `state`).
|
||||
- Edge case: zero runs with stored PRs → CLI prints "No pull requests found." with **zero** `get_run_pull_request` calls (assert via mock counter).
|
||||
- Edge case: only 3 runs with stored PRs → exactly 3 `get_run_pull_request` calls; concurrency cap not exceeded.
|
||||
- Error path: one candidate's view returns 502 → that entry shows `state: "unknown"`; other entries still populated.
|
||||
- Error path: server returns 503 for missing creds on any view → CLI surfaces a single top-level "GitHub integration unavailable" message.
|
||||
- JSON mode: `--json` returns the assembled list with `merged: bool` on each row.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it 'cmd::pr_list'` passes.
|
||||
- CLI `pr/list.rs` no longer calls `fabro_github::get_pull_request`.
|
||||
- No new handler added to `server.rs`.
|
||||
- Test asserting the skip behavior: runs without stored PRs are provably **not** probed against GitHub.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 5: Add `pr create` endpoint + rewrite CLI**
|
||||
|
||||
**Goal:** The worst offender. Server owns the entire create flow.
|
||||
|
||||
**Requirements:** R1, R2, R3, R4, R5
|
||||
|
||||
**Dependencies:** Unit 1 (schemas reused).
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api-reference/fabro-api.yaml` — add `POST /api/v1/runs/{id}/pull_request` + request body `{ force: bool, model: Option<String> }` + response `PullRequestRecord`.
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/pull_request.rs` — add a `base_url: &str` parameter to `maybe_open_pull_request(...)`. Thread it through to the two internal outbound calls: `github_app::create_pull_request(..., base_url)` at line 440 and `github_app::enable_auto_merge(..., base_url)` at line 457 (both currently hardcode `&github_app::github_api_base_url()`). Also thread through to `build_pr_body(...)` if it makes any GitHub call; review the helper for other `github_api_base_url()` uses while touching it. Update the existing in-pipeline caller (`pipeline::pull_request` function) to pass its current `&github_api_base_url()` value explicitly (preserving today's behavior for that path — hardening it is out of scope but now trivially possible).
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` — add handler `create_run_pull_request`. Handler passes `state.github_api_base_url.as_str()` into `maybe_open_pull_request` **and** into any direct `fabro_github::*` call (e.g., `branch_exists`).
|
||||
- Modify: `lib/crates/fabro-client/src/client.rs` — add `Client::create_run_pull_request(run_id, body)`.
|
||||
- Rewrite: `lib/crates/fabro-cli/src/commands/pr/create.rs` — parse args, build request, call client, print.
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_create.rs`; stage-scope-consumer audit tests (see below).
|
||||
|
||||
**Pre-flight audit (part of this unit):**
|
||||
- **Consumer audit for stage-less `PullRequestCreated` envelope.** Before landing this unit, enumerate every code path that reads `stage_id`/`node_id` off a stored `RunEvent` and branches or groups on it. Known candidates to inspect: run_progress UI (`lib/crates/fabro-cli/src/commands/run/run_progress/...`), SSE event replay (server streams), `RunProjection` hydration reducer arms, any analytics/summary code that buckets by stage. For each, either (a) verify it already tolerates `stage_id = None`, or (b) add explicit handling. Add at least one integration test per consumer that constructs a `PullRequestCreated` event with no stage scope and asserts the consumer's behavior.
|
||||
|
||||
**Approach:**
|
||||
- Handler reads `RunProjection` from `state.store.open_run(&id)`. Validates, in order:
|
||||
- `run_spec.repo_origin_url` is present (else 400 `missing_repo_origin`).
|
||||
- `run_spec.base_branch` is present (else 400 `missing_base_branch`).
|
||||
- `start.run_branch` is present (else 400 `missing_run_branch`).
|
||||
- `state.final_patch` is non-empty after trimming whitespace — i.e., `!state.final_patch.trim().is_empty()` (else 400 `empty_diff`). Preserves current CLI behavior at `pr/create.rs:64`.
|
||||
- `conclusion.status ∈ {Success, PartialSuccess}` unless `body.force`.
|
||||
- `state.pull_request` is `None` (else 409 `conflict` with the existing record).
|
||||
- Host parsed from `run_spec.repo_origin_url` (after `ssh_url_to_https` normalization) equals `github.com` (else 400 `unsupported_host`). On create, `run_spec.repo_origin_url` is the source of truth because no `PullRequestRecord` exists yet. Once this endpoint persists the record, later view/merge/close calls parse the host from `record.html_url` instead.
|
||||
- Load GitHub creds via `AppState::github_credentials(settings)`. If missing, return 503 `integration_unavailable` (generic external body; detailed diagnostic in log only; authorizer runs first).
|
||||
- Every outbound GitHub call from this handler receives `state.github_api_base_url.as_str()` — the value captured at `AppState` construction. This includes both the direct `fabro_github::branch_exists(...)` call and the indirect calls made inside `maybe_open_pull_request(...)`, which is why that helper grows a `base_url: &str` parameter in this unit. Do not call `fabro_github::github_api_base_url()` at request time from either the handler or the helper.
|
||||
- Call `fabro_github::branch_exists(...)`. If missing, return 400 with a clear message referencing `git push origin <run_branch>`.
|
||||
- Load LLM provider catalog via `fabro_auth::configured_providers_from_process_env(state.vault.as_ref())` (the helper `operations/start.rs:322` uses). Pick model: `body.model.clone().unwrap_or_else(|| Catalog::builtin().default_for_configured(&configured).id)`. Server process env + server vault — not client env.
|
||||
- Call `fabro_workflow::pull_request::maybe_open_pull_request(...)` with `draft = true` (matches current CLI behavior; no new flag) and `state.github_api_base_url.as_str()` as the new `base_url` parameter. `maybe_open_pull_request` does **not** emit events — the caller owns emission.
|
||||
- On `maybe_open_pull_request` returning `Some(record)`:
|
||||
- Emit `PullRequestCreated` via `fabro_workflow::event::append_event(&run_store, &run_id, &Event::PullRequestCreated { ... })` (`event.rs:2628`). Stage-less envelope (no `emit_scoped`).
|
||||
- Return `Json(record)`.
|
||||
- On `maybe_open_pull_request` returning `None` (shouldn't happen because empty-diff is rejected upstream, but defensively): return 500 — unexpected.
|
||||
- CLI rewrite: delete `rebuild_run_store` call, `ensure_matching_repo_origin`, `detect_repo_info`, `configured_providers_from_process_env`, `Catalog::builtin()` model pick, `fabro_github::*` calls, `maybe_open_pull_request` call, `load_github_credentials_required` call. Build request body from `args.force` + `args.model`, call client, print `record.html_url` (text mode) or the record as JSON.
|
||||
|
||||
**Patterns to follow:**
|
||||
- In-run PR creation in `lib/crates/fabro-workflow/src/pipeline/pull_request.rs:492-560` — the handler is a simplified version of `pipeline::pull_request` that runs on demand rather than as a pipeline stage.
|
||||
- Event emission via `fabro_workflow::event::append_event` (`event.rs:2628`).
|
||||
- 409 Conflict pattern — search server.rs for existing 409 uses and mirror.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: completed dry-run with stored `final_patch` + pushed branch → endpoint creates PR via GitHub, emits `PullRequestCreated` (stage-less), returns record; CLI prints URL.
|
||||
- Happy path + `--force`: run in `Failed` state → normally rejected; with `force: true` → proceeds.
|
||||
- Happy path + `--model`: body.model set → server uses it verbatim.
|
||||
- Edge case: empty `final_patch` → 400 `empty_diff`; CLI exits non-zero.
|
||||
- Edge case: whitespace-only `final_patch` (e.g., `"\n\n \n"`) → 400 `empty_diff`, matching today's CLI behavior (`diff.trim().is_empty()`).
|
||||
- Edge case: run already has `state.pull_request` → 409 with existing record in body; CLI prints "PR already exists at URL".
|
||||
- **Nongit run:** `run_spec.repo_origin_url` is None → 400 `missing_repo_origin`; same for missing `base_branch` (400 `missing_base_branch`) and missing `start.run_branch` (400 `missing_run_branch`). Proves nongit runs are still valid runs — they just can't have PRs.
|
||||
- Error path: GitHub branch doesn't exist → 400 referencing `git push origin <run_branch>`.
|
||||
- Error path: server GitHub creds missing → 503 `integration_unavailable` (generic body).
|
||||
- Error path: host parsed from `run_spec.repo_origin_url` is not `github.com` → 400 `unsupported_host`; no outbound HTTP made.
|
||||
- Integration: event appended to run store is readable via `GET /runs/{id}/state` → `state.pull_request` populated. Regression test against the current bug where client-side create never wrote back.
|
||||
- Consumer-audit tests (see Pre-flight): each identified stage_id-grouping consumer tolerates a stage-less `PullRequestCreated`.
|
||||
- JSON mode: `--json` on success returns the `PullRequestRecord`.
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli --test it 'cmd::pr_create'` passes.
|
||||
- After a successful `fabro pr create`, a subsequent `fabro pr view` against the same run returns a populated `PullRequestRecord` from server state. (This was previously broken.)
|
||||
- `pr/create.rs` has no `fabro_github::`, `fabro_workflow::`, `fabro_sandbox::`, or `fabro_store::` imports.
|
||||
- Consumer-audit list is written into the PR description with each consumer marked verified.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 6: Delete client-side PR infrastructure**
|
||||
|
||||
**Goal:** Remove the dead client-side helpers and the `boundary-exempt` annotations they carry.
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** Units 1-5 complete.
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/mod.rs` — delete `load_github_credentials_required`, `load_pr_record`, `GITHUB_CREDENTIALS_REQUIRED` constant, and the `#[allow(deprecated, reason = "boundary-exempt(pr-api): …")]` annotations. `dispatch` stays.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/create.rs` — remove `#[allow(deprecated, …)]` if it survived Unit 5.
|
||||
- Modify: `lib/crates/fabro-cli/Cargo.toml` — if `fabro-github` and `fabro-workflow` are no longer used anywhere in the PR command module (check other commands), don't remove from `[dependencies]` since other commands still use them. This is a no-op verification step — just confirm the crate dep graph is still correct.
|
||||
|
||||
**Approach:**
|
||||
- Grep the CLI crate for remaining `fabro_github::` and `load_github_credentials` calls after Units 1-5. Anything left is a miss; go fix.
|
||||
- Verify no `boundary-exempt(pr-api)` annotations remain: `grep -r "boundary-exempt(pr-api)" lib/crates/fabro-cli/`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — pure deletion of dead code. Existing integration tests from Units 1-5 prove nothing regressed.
|
||||
|
||||
**Verification:**
|
||||
- `grep -r "boundary-exempt(pr-api)" lib/crates/fabro-cli/` returns no results.
|
||||
- `grep -r "load_github_credentials_required\|load_pr_record" lib/crates/fabro-cli/` returns no results.
|
||||
- `cargo +nightly-2026-04-14 clippy -p fabro-cli --all-targets -- -D warnings` is clean (no unused imports, no dead code warnings).
|
||||
- Full `cargo nextest run -p fabro-cli --test it 'cmd::pr'` passes.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Server handlers plug into the existing `AppState::github_credentials` + run-store event pipeline. `pr create` emits `PullRequestCreated` via `fabro_workflow::event::append_event` (stage-less envelope — pre-flight consumer audit in Unit 5). `pr merge` and `pr close` call GitHub and return — they do not write to run state.
|
||||
- **Error propagation:** Errors from `fabro_github::*` now surface as HTTP status + JSON body instead of anyhow errors in the CLI. Status codes are structured: 400 for input errors (missing git metadata, empty diff, unsupported host), 404 for no stored record, 502 for GitHub-said-not-found, 503 for missing server creds, 409 for already-exists, 500 only for actual code bugs. CLI prints the server's error message.
|
||||
- **State lifecycle risks:** Creating a PR now writes back to the authoritative server run store (fixes the existing bug where client-side create never persisted the record). **State beyond creation is not tracked.** After merge or close, `state.pull_request` is unchanged — Fabro does not mirror GitHub's merge/close lifecycle. `pr view` and `pr list` always re-read from GitHub for current status.
|
||||
- **API surface parity:** Four new operations added to OpenAPI (view, create, merge, close); `pr list` has no new endpoint. **No changes to `RunSpec` schema.** TypeScript client regenerates automatically; run `bin/dev/docker-build.sh` + `scripts/refresh-fabro-spa.sh` once after OpenAPI changes land.
|
||||
- **Integration coverage:** Cross-boundary test — after `fabro pr create`, a `fabro pr view` against the same run returns the populated record. Unit 5 test scenarios cover this.
|
||||
- **Unchanged invariants:** `PullRequestRecord` field shape; `fabro_github::*` surface; in-run pipeline PR stage (still creates + emits `PullRequestCreated` with its stage scope when the pipeline is configured for PR creation); `RunSpec.repo_origin_url` + `RunSpec.base_branch` stay `Option<String>`; runs without git metadata remain valid.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Server has no GitHub creds configured → all four endpoints 503. Users on self-hosted installs without GitHub App setup hit this first. | Error body is generic externally (`integration_unavailable`); detailed diagnostic in log only; authorizer runs first so probing is bounded. Document GitHub App setup in `docs/`. Consider a follow-up `fabro doctor` check. |
|
||||
| Breaking the CLI UX — stdout/stderr shape drifting from the current snapshots. | Existing `pr_*.rs` integration tests use snapshots. Keep them passing; update only where the server legitimately produces different error text. Preserve the existing command/flag surface. |
|
||||
| Progenitor generated `PullRequestRecord` doesn't match `fabro_types::PullRequestRecord` byte-for-byte → `with_replacement` silently generates a parallel type. | Unit 1 adds a fabro-api JSON round-trip parity test over a shared fixture, covering every inclusion site as later units add them. Per CLAUDE.md. |
|
||||
| **Blast-radius expansion of service bearer token.** Any authenticated fabro service token now authorizes GitHub merge/close under the server's App identity on every App-installed repo. In the old model, a leaked token was one dev's problem; now it's org-wide. | Accepted as a consistent expansion of existing fabro auth semantics (`archive_run`, `cancel_run` are similar). Documented in Threat Model. Follow-up if fabro gains per-subject GitHub permission checks. |
|
||||
| **Loss of per-user GitHub attribution.** Today PRs are authored/merged/closed under the user's GitHub identity. After this refactor all actions are the server's App identity. No new audit-event infrastructure is added in this refactor. | Accepted and documented. Future work may add a general audit-event surface; this plan does not. |
|
||||
| **TOCTOU: duplicate PRs from concurrent `pr create` on same run.** Two simultaneous calls both see `state.pull_request = None`, both call GitHub, both create real PRs before either emits the event. | Accepted. Observable symptom: duplicate GitHub PRs; user closes the extra. Collision window is narrow (concurrent manual invocations on the same run). Documented here so it's not discovered at runtime. |
|
||||
| **LLM billing / provider shift.** Today PR body generation spends the user's LLM quota; after this refactor it spends the server operator's quota on the server's configured providers. May also silently pick a different model than the user had locally. | Accepted and documented. This plan does not add cost-ceiling or model-allowlist infrastructure. Follow-up if needed. |
|
||||
| **Live-GitHub dependency for display.** `pr view` and `pr list` now hit GitHub every time. If GitHub is down or the server's creds are revoked, display fails. Today's CLI has the same dependency but from the client side; the shift is operational, not functional. | Accepted. `pr list` skips runs without a stored record before any GitHub call, so most deployments won't stress this path. |
|
||||
| `rebuild_run_store` still used by `fork`/`rewind` — not deleted in this plan. | Scope boundary explicitly documents it stays. Future `fork`/`rewind` plan handles removal. |
|
||||
| Stage-less `PullRequestCreated` envelope differs from pipeline-stage-scoped emission. Downstream consumers that group by `stage_id` could regress. | Unit 5 includes a mandatory consumer-audit pre-flight listing every known consumer + integration tests asserting each tolerates `stage_id = None`. PR description must reference completed audit list. |
|
||||
| **CLI `--json` output becomes richer for `pr view` / `pr list`** than the old CLI emitted (e.g., more fields from `PullRequestDetail`). | Intentional contract change — called out in the Endpoint summary note. Human-readable output stays as close to current as practical. |
|
||||
|
||||
### Threat Model
|
||||
|
||||
After this refactor the fabro server holds GitHub App credentials and performs GitHub writes on behalf of clients. Two plan-level threats:
|
||||
|
||||
- **Leaked service bearer token → unrestricted merge on every App-installed repo.** Old model: each user held their own token; blast radius = one dev. New model: any authenticated fabro client can call `POST /runs/{id}/pull_request/merge`. **Accepted** — see Risks row above. Consistent with how existing run-scoped mutating endpoints (`archive_run`, `cancel_run`) already work.
|
||||
- **Attacker-controlled `repo_origin_url` → SSRF with GitHub Authorization headers.** A crafted origin URL could end up in an outbound HTTP request with the server's App JWT as Authorization, leaking the installation token. **Mitigated** by the required github.com-only host check in Units 1/2/3/5 before any outbound GitHub call; Authorization headers never attached to other hosts. Negative test required.
|
||||
|
||||
(The "prompt-injected LLM PR body" concern from earlier reviews was deferred — the "Generated by fabro" disclaimer was considered but is out of scope for this narrowly-focused refactor.)
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Update `docs/api-reference/fabro-api.yaml` — this is both code and docs. The Mintlify docs pick it up automatically.
|
||||
- No user-facing `docs/changelog/` entry needed; CLI UX is unchanged from the user's perspective (same commands, same output). Internal changelog / PR description covers the refactor.
|
||||
- **Rollout order: upgrade server first, then CLIs.** New CLI against old server gets 404 on the new paths; add a CLI-side capability hint or clear error that directs users to upgrade the server. Old CLI against new server keeps working against the old-path behavior for now (client-side GitHub creds), though authorship will differ from server-issued PRs. Deprecate the client-side GitHub-creds path in the release after this plan lands; remove it in the release after that.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Audit source (this-conversation): `fabro pr *` boundary audit; `dump` refactor precedent.
|
||||
- Precedent commit: `1481ecf2a refactor(dump): test the real server boundary, drop client-side storage fakes`.
|
||||
- Related code:
|
||||
- CLI PR commands: `lib/crates/fabro-cli/src/commands/pr/{mod,create,view,list,merge,close}.rs`.
|
||||
- Shared helper to delete: `lib/crates/fabro-cli/src/commands/pr/mod.rs::load_github_credentials_required` (`pr/mod.rs:35`).
|
||||
- Server handler templates: `lib/crates/fabro-server/src/server.rs::{archive_run,cancel_run,append_run_event}` (`server.rs:6448, 6085, 4990`).
|
||||
- Server GitHub creds helper: `lib/crates/fabro-server/src/server.rs::AppState::github_credentials` (`server.rs:695`).
|
||||
- In-run PR stage: `lib/crates/fabro-workflow/src/pipeline/pull_request.rs::{maybe_open_pull_request, pull_request}` (`pull_request.rs:405, 492`).
|
||||
- PR event variants: `lib/crates/fabro-workflow/src/event.rs:498-510`.
|
||||
- OpenAPI path template: `docs/api-reference/fabro-api.yaml:940-971` (`appendRunEvent`).
|
||||
- OpenAPI PullRequest schema (existing): `docs/api-reference/fabro-api.yaml:4227-4247` (`RunPullRequest` — keep as-is, add new `PullRequestRecord` separately).
|
||||
- Client method templates: `lib/crates/fabro-client/src/client.rs::{cancel_run, create_secret}` (`client.rs:717, 525`).
|
||||
- Type definition: `lib/crates/fabro-types/src/pull_request.rs::PullRequestRecord`.
|
||||
|
|
@ -802,10 +802,6 @@ pub(crate) struct RunWorkerArgs {
|
|||
#[arg(long, hide = true)]
|
||||
pub(crate) storage_dir: Option<PathBuf>,
|
||||
|
||||
/// Short-lived bearer token for artifact uploads
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) artifact_upload_token: Option<String>,
|
||||
|
||||
/// Run scratch directory
|
||||
#[arg(long)]
|
||||
pub(crate) run_dir: PathBuf,
|
||||
|
|
|
|||
|
|
@ -7,15 +7,9 @@ use std::io::ErrorKind;
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bytes::Bytes;
|
||||
#[cfg(test)]
|
||||
use fabro_store::{ArtifactStore, RunDatabase};
|
||||
use fabro_store::{EventEnvelope, RunProjection, StageId};
|
||||
use fabro_types::{RunBlobId, RunId};
|
||||
use fabro_store::{RunProjection, StageId};
|
||||
use fabro_types::RunId;
|
||||
use fabro_workflow::run_dump::RunDump;
|
||||
use futures::future::BoxFuture;
|
||||
#[cfg(test)]
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::args::DumpArgs;
|
||||
|
|
@ -29,8 +23,7 @@ pub(crate) async fn run(args: &DumpArgs, base_ctx: &CommandContext) -> Result<()
|
|||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&args.run).await?.run_id;
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
let source = ServerDumpSource::new(client.as_ref(), &run_id);
|
||||
let file_count = export_run_from_source(&source, &state, &args.output).await?;
|
||||
let file_count = export_run(client.as_ref(), &run_id, &state, &args.output).await?;
|
||||
if ctx.json_output() {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
"run_id": run_id,
|
||||
|
|
@ -48,20 +41,73 @@ pub(crate) async fn run(args: &DumpArgs, base_ctx: &CommandContext) -> Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn export_run(
|
||||
run_store: &RunDatabase,
|
||||
artifact_store: &ArtifactStore,
|
||||
async fn export_run(
|
||||
client: &Client,
|
||||
run_id: &RunId,
|
||||
state: &RunProjection,
|
||||
output_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
let state = run_store.state().await?;
|
||||
let run_id = state
|
||||
.spec
|
||||
.as_ref()
|
||||
.map(|run| run.run_id)
|
||||
.context("run has no data in the store")?;
|
||||
let source = LocalDumpSource::new(run_store, artifact_store, run_id);
|
||||
export_run_from_source(&source, &state, output_dir).await
|
||||
let output_state = inspect_output_dir(output_dir)?;
|
||||
let staging_parent = output_parent_dir(output_dir);
|
||||
std::fs::create_dir_all(staging_parent)
|
||||
.with_context(|| format!("failed to create {}", staging_parent.display()))?;
|
||||
|
||||
let staging_dir = tempfile::Builder::new()
|
||||
.prefix(".fabro-dump-")
|
||||
.tempdir_in(staging_parent)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create staging dir in {}",
|
||||
staging_parent.display()
|
||||
)
|
||||
})?;
|
||||
let staging_path = staging_dir.path().to_path_buf();
|
||||
|
||||
let file_count = write_run_dump(client, run_id, state, &staging_path).await?;
|
||||
finalize_export(
|
||||
output_dir,
|
||||
output_state,
|
||||
staging_dir,
|
||||
&staging_path,
|
||||
file_count,
|
||||
)
|
||||
}
|
||||
|
||||
async fn write_run_dump(
|
||||
client: &Client,
|
||||
run_id: &RunId,
|
||||
state: &RunProjection,
|
||||
output_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
let events = client.list_run_events(run_id, None, None).await?;
|
||||
let mut dump = RunDump::from_store_state_and_events(state, &events)?;
|
||||
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_id| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_id).await })
|
||||
})
|
||||
.await?;
|
||||
|
||||
for artifact in client.list_run_artifacts(run_id).await? {
|
||||
let stage_id: StageId = artifact
|
||||
.stage_id
|
||||
.parse()
|
||||
.with_context(|| format!("server returned invalid stage id {:?}", artifact.stage_id))?;
|
||||
let data = client
|
||||
.download_stage_artifact(run_id, &stage_id, &artifact.relative_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to download artifact {} for stage {}",
|
||||
artifact.relative_path, artifact.stage_id
|
||||
)
|
||||
})?;
|
||||
dump.add_artifact_bytes(&stage_id, &artifact.relative_path, data)?;
|
||||
}
|
||||
|
||||
let output_dir = output_dir.to_path_buf();
|
||||
spawn_blocking(move || dump.write_to_dir(&output_dir))
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("run dump write task failed: {err}"))?
|
||||
}
|
||||
|
||||
fn finalize_export(
|
||||
|
|
@ -87,175 +133,6 @@ fn finalize_export(
|
|||
Ok(file_count)
|
||||
}
|
||||
|
||||
struct DumpArtifact {
|
||||
stage_id: StageId,
|
||||
relative_path: String,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
trait DumpDataSource {
|
||||
fn list_events(&self) -> BoxFuture<'_, Result<Vec<EventEnvelope>>>;
|
||||
|
||||
fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result<Option<Bytes>>>;
|
||||
|
||||
fn list_artifacts(&self) -> BoxFuture<'_, Result<Vec<DumpArtifact>>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct LocalDumpSource<'a> {
|
||||
run_store: &'a RunDatabase,
|
||||
artifact_store: &'a ArtifactStore,
|
||||
run_id: RunId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl<'a> LocalDumpSource<'a> {
|
||||
fn new(run_store: &'a RunDatabase, artifact_store: &'a ArtifactStore, run_id: RunId) -> Self {
|
||||
Self {
|
||||
run_store,
|
||||
artifact_store,
|
||||
run_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl DumpDataSource for LocalDumpSource<'_> {
|
||||
fn list_events(&self) -> BoxFuture<'_, Result<Vec<EventEnvelope>>> {
|
||||
Box::pin(async move { Ok(self.run_store.list_events().await?) })
|
||||
}
|
||||
|
||||
fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result<Option<Bytes>>> {
|
||||
Box::pin(async move { Ok(self.run_store.read_blob(&blob_id).await?) })
|
||||
}
|
||||
|
||||
fn list_artifacts(&self) -> BoxFuture<'_, Result<Vec<DumpArtifact>>> {
|
||||
Box::pin(async move {
|
||||
let mut artifacts = Vec::new();
|
||||
for asset in self.artifact_store.list_for_run(&self.run_id).await? {
|
||||
let data = self
|
||||
.artifact_store
|
||||
.get(&self.run_id, &asset.node, &asset.filename)
|
||||
.await?
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"asset {:?} for node {:?} visit {} is missing from the store",
|
||||
asset.filename,
|
||||
asset.node.node_id(),
|
||||
asset.node.visit()
|
||||
)
|
||||
})?;
|
||||
artifacts.push(DumpArtifact {
|
||||
stage_id: asset.node,
|
||||
relative_path: asset.filename,
|
||||
data: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Ok(artifacts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerDumpSource<'a> {
|
||||
client: &'a Client,
|
||||
run_id: &'a RunId,
|
||||
}
|
||||
|
||||
impl<'a> ServerDumpSource<'a> {
|
||||
fn new(client: &'a Client, run_id: &'a RunId) -> Self {
|
||||
Self { client, run_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl DumpDataSource for ServerDumpSource<'_> {
|
||||
fn list_events(&self) -> BoxFuture<'_, Result<Vec<EventEnvelope>>> {
|
||||
Box::pin(async move { self.client.list_run_events(self.run_id, None, None).await })
|
||||
}
|
||||
|
||||
fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result<Option<Bytes>>> {
|
||||
Box::pin(async move { self.client.read_run_blob(self.run_id, &blob_id).await })
|
||||
}
|
||||
|
||||
fn list_artifacts(&self) -> BoxFuture<'_, Result<Vec<DumpArtifact>>> {
|
||||
Box::pin(async move {
|
||||
let mut artifacts = Vec::new();
|
||||
for artifact in self.client.list_run_artifacts(self.run_id).await? {
|
||||
let stage_id: StageId = artifact.stage_id.parse().with_context(|| {
|
||||
format!("server returned invalid stage id {:?}", artifact.stage_id)
|
||||
})?;
|
||||
let data = self
|
||||
.client
|
||||
.download_stage_artifact(self.run_id, &stage_id, &artifact.relative_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to download artifact {} for stage {}",
|
||||
artifact.relative_path, artifact.stage_id
|
||||
)
|
||||
})?;
|
||||
artifacts.push(DumpArtifact {
|
||||
stage_id,
|
||||
relative_path: artifact.relative_path,
|
||||
data,
|
||||
});
|
||||
}
|
||||
Ok(artifacts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn export_run_from_source(
|
||||
source: &impl DumpDataSource,
|
||||
state: &RunProjection,
|
||||
output_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
let output_state = inspect_output_dir(output_dir)?;
|
||||
let staging_parent = output_parent_dir(output_dir);
|
||||
std::fs::create_dir_all(staging_parent)
|
||||
.with_context(|| format!("failed to create {}", staging_parent.display()))?;
|
||||
|
||||
let staging_dir = tempfile::Builder::new()
|
||||
.prefix(".fabro-dump-")
|
||||
.tempdir_in(staging_parent)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create staging dir in {}",
|
||||
staging_parent.display()
|
||||
)
|
||||
})?;
|
||||
let staging_path = staging_dir.path().to_path_buf();
|
||||
|
||||
let file_count = write_run_dump(source, state, &staging_path).await?;
|
||||
finalize_export(
|
||||
output_dir,
|
||||
output_state,
|
||||
staging_dir,
|
||||
&staging_path,
|
||||
file_count,
|
||||
)
|
||||
}
|
||||
|
||||
async fn write_run_dump(
|
||||
source: &impl DumpDataSource,
|
||||
state: &RunProjection,
|
||||
output_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
let events = source.list_events().await?;
|
||||
let mut dump = RunDump::from_store_state_and_events(state, &events)?;
|
||||
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_id| source.read_blob(blob_id))
|
||||
.await?;
|
||||
|
||||
for artifact in source.list_artifacts().await? {
|
||||
dump.add_artifact_bytes(&artifact.stage_id, &artifact.relative_path, artifact.data)?;
|
||||
}
|
||||
|
||||
let output_dir = output_dir.to_path_buf();
|
||||
spawn_blocking(move || dump.write_to_dir(&output_dir))
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("run dump write task failed: {err}"))?
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OutputDirState {
|
||||
Missing,
|
||||
|
|
@ -300,518 +177,8 @@ fn output_parent_dir(path: &Path) -> &Path {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_store::{Database, EventEnvelope, EventPayload};
|
||||
use fabro_types::{
|
||||
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
|
||||
NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, SandboxRecord, StageStatus,
|
||||
StartRecord, SuccessReason, WorkflowSettings, fixtures,
|
||||
};
|
||||
use fabro_workflow::event::{Event, append_event};
|
||||
use object_store::ObjectStore;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dt(rfc3339: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(rfc3339)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn test_run_id() -> RunId {
|
||||
fixtures::RUN_1
|
||||
}
|
||||
|
||||
fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
|
||||
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||
let store = Arc::new(Database::new(
|
||||
Arc::clone(&object_store),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
));
|
||||
let artifact_store = ArtifactStore::new(object_store, "artifacts");
|
||||
(store, artifact_store)
|
||||
}
|
||||
|
||||
fn sample_run_spec(run_id: RunId, _created_at: DateTime<Utc>) -> RunSpec {
|
||||
let mut graph = Graph::new("night-sky");
|
||||
graph.attrs.insert(
|
||||
"goal".to_string(),
|
||||
AttrValue::String("map the constellations".to_string()),
|
||||
);
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
workflow_slug: Some("night-sky".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/night-sky"),
|
||||
host_repo_path: Some("github.com/fabro-sh/fabro".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "infra".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_start_record(run_id: RunId, created_at: DateTime<Utc>) -> StartRecord {
|
||||
StartRecord {
|
||||
run_id,
|
||||
start_time: created_at + chrono::Duration::seconds(5),
|
||||
run_branch: Some(format!("fabro/run/{run_id}")),
|
||||
base_sha: Some("abc123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint {
|
||||
Checkpoint {
|
||||
timestamp: dt("2026-03-27T12:10:00Z"),
|
||||
current_node: current_node.to_string(),
|
||||
completed_nodes: vec!["plan".to_string()],
|
||||
node_retries: HashMap::from([(
|
||||
current_node.to_string(),
|
||||
visit.saturating_sub(1),
|
||||
)]),
|
||||
context_values: HashMap::from([(
|
||||
"artifact".to_string(),
|
||||
serde_json::json!({"kind": "summary"}),
|
||||
)]),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: Some("review".to_string()),
|
||||
git_commit_sha: Some("def456".to_string()),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits: HashMap::from([(current_node.to_string(), visit as usize)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_conclusion() -> Conclusion {
|
||||
Conclusion {
|
||||
timestamp: dt("2026-03-27T12:15:00Z"),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 3210,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: Some("feedbeef".to_string()),
|
||||
stages: Vec::new(),
|
||||
billing: Some(BilledTokenCounts {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
total_tokens: 150,
|
||||
reasoning_tokens: 50,
|
||||
cache_read_tokens: 30,
|
||||
cache_write_tokens: 40,
|
||||
total_usd_micros: Some(1_250_000),
|
||||
}),
|
||||
total_retries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_retro(run_id: RunId) -> Retro {
|
||||
Retro {
|
||||
run_id,
|
||||
workflow_name: "night-sky".to_string(),
|
||||
goal: "map the constellations".to_string(),
|
||||
timestamp: dt("2026-03-27T12:20:00Z"),
|
||||
smoothness: None,
|
||||
stages: Vec::new(),
|
||||
stats: AggregateStats {
|
||||
total_duration_ms: 3210,
|
||||
total_billing_usd_micros: Some(1_250_000),
|
||||
total_retries: 2,
|
||||
files_touched: vec!["src/lib.rs".to_string()],
|
||||
stages_completed: 3,
|
||||
stages_failed: 0,
|
||||
},
|
||||
intent: Some("ship the fix".to_string()),
|
||||
outcome: Some("done".to_string()),
|
||||
learnings: None,
|
||||
friction_points: None,
|
||||
open_items: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_sandbox() -> SandboxRecord {
|
||||
SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/night-sky".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
host_working_directory: Some("/tmp/night-sky".to_string()),
|
||||
container_mount_point: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json<T: DeserializeOwned>(path: &Path) -> T {
|
||||
let bytes = std::fs::read(path)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
|
||||
serde_json::from_slice(&bytes)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_run_writes_expected_directory_tree() {
|
||||
let (store, artifact_store) = test_store_bundle();
|
||||
let created_at = dt("2026-03-27T12:00:00Z");
|
||||
let run_id = test_run_id();
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
let run_spec = sample_run_spec(run_id, created_at);
|
||||
let start_record = sample_start_record(run_id, created_at);
|
||||
let mut first_checkpoint = sample_checkpoint("plan", 1);
|
||||
let mut second_checkpoint = sample_checkpoint("code", 2);
|
||||
let conclusion = sample_conclusion();
|
||||
let retro = sample_retro(run_id);
|
||||
let sandbox = sample_sandbox();
|
||||
let summary_blob = run.write_blob(br#"{"done":true}"#).await.unwrap();
|
||||
let plan_blob = run.write_blob(br#"{"steps":3}"#).await.unwrap();
|
||||
first_checkpoint.context_values.insert(
|
||||
"artifact".to_string(),
|
||||
serde_json::json!(fabro_types::format_blob_ref(&plan_blob)),
|
||||
);
|
||||
second_checkpoint.context_values.insert(
|
||||
"artifact".to_string(),
|
||||
serde_json::json!(fabro_types::format_blob_ref(&summary_blob)),
|
||||
);
|
||||
|
||||
let node = StageId::new("code", 2);
|
||||
append_event(&run, &run_id, &Event::RunCreated {
|
||||
run_id,
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: Some("digraph night_sky {}".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: "/tmp/night-sky-run".to_string(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::WorkflowRunStarted {
|
||||
name: "night-sky".to_string(),
|
||||
run_id,
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
base_sha: start_record.base_sha.clone(),
|
||||
run_branch: start_record.run_branch.clone(),
|
||||
worktree_dir: None,
|
||||
goal: Some("map the constellations".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RunRunning)
|
||||
.await
|
||||
.unwrap();
|
||||
for checkpoint in [&first_checkpoint, &second_checkpoint] {
|
||||
append_event(&run, &run_id, &Event::CheckpointCompleted {
|
||||
node_id: checkpoint.current_node.clone(),
|
||||
status: "success".to_string(),
|
||||
current_node: checkpoint.current_node.clone(),
|
||||
completed_nodes: checkpoint.completed_nodes.clone(),
|
||||
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
|
||||
context_values: checkpoint.context_values.clone().into_iter().collect(),
|
||||
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
|
||||
next_node_id: checkpoint.next_node_id.clone(),
|
||||
git_commit_sha: checkpoint.git_commit_sha.clone(),
|
||||
loop_failure_signatures: checkpoint
|
||||
.loop_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
restart_failure_signatures: checkpoint
|
||||
.restart_failure_signatures
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(signature, count)| (signature.to_string(), count))
|
||||
.collect(),
|
||||
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
|
||||
diff: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
append_event(&run, &run_id, &Event::SandboxInitialized {
|
||||
working_directory: sandbox.working_directory.clone(),
|
||||
provider: sandbox.provider.clone(),
|
||||
identifier: sandbox.identifier.clone(),
|
||||
host_working_directory: sandbox.host_working_directory.clone(),
|
||||
container_mount_point: sandbox.container_mount_point.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::Prompt {
|
||||
stage: "code".to_string(),
|
||||
visit: 2,
|
||||
text: "Plan the fix".to_string(),
|
||||
mode: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::PromptCompleted {
|
||||
node_id: "code".to_string(),
|
||||
response: "Implemented".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
provider: "openai".to_string(),
|
||||
billing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::StageCompleted {
|
||||
node_id: "code".to_string(),
|
||||
name: "Code".to_string(),
|
||||
index: 1,
|
||||
duration_ms: 250,
|
||||
status: "partial_success".to_string(),
|
||||
preferred_label: None,
|
||||
suggested_next_ids: Vec::new(),
|
||||
billing: None,
|
||||
failure: None,
|
||||
notes: Some("captured output".to_string()),
|
||||
files_touched: Vec::new(),
|
||||
context_updates: None,
|
||||
jump_to_node: None,
|
||||
context_values: None,
|
||||
node_visits: Some(std::collections::BTreeMap::from([(
|
||||
"code".to_string(),
|
||||
2usize,
|
||||
)])),
|
||||
loop_failure_signatures: None,
|
||||
restart_failure_signatures: None,
|
||||
response: Some("Implemented".to_string()),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::CommandStarted {
|
||||
node_id: "code".to_string(),
|
||||
script: "echo hi".to_string(),
|
||||
command: "echo hi".to_string(),
|
||||
language: "sh".to_string(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::CommandCompleted {
|
||||
node_id: "code".to_string(),
|
||||
stdout: "stdout line".to_string(),
|
||||
stderr: String::new(),
|
||||
exit_code: Some(0),
|
||||
duration_ms: 100,
|
||||
timed_out: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RetroStarted {
|
||||
prompt: Some("How did it go?".to_string()),
|
||||
provider: None,
|
||||
model: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::RetroCompleted {
|
||||
duration_ms: 50,
|
||||
response: Some("Smooth enough".to_string()),
|
||||
retro: Some(serde_json::to_value(&retro).unwrap()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
append_event(&run, &run_id, &Event::WorkflowRunCompleted {
|
||||
duration_ms: conclusion.duration_ms,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: SuccessReason::Completed,
|
||||
total_usd_micros: conclusion
|
||||
.billing
|
||||
.as_ref()
|
||||
.and_then(|billing| billing.total_usd_micros),
|
||||
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
|
||||
final_patch: None,
|
||||
billing: conclusion.billing.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
run.append_event(
|
||||
&EventPayload::new(
|
||||
serde_json::json!({
|
||||
"id": format!("evt-{run_id}-stage-completed"),
|
||||
"ts": "2026-03-27T12:00:01.000Z",
|
||||
"run_id": run_id.to_string(),
|
||||
"event": "stage.completed",
|
||||
"node_id": "code",
|
||||
"node_label": "Code",
|
||||
"properties": {
|
||||
"index": 1,
|
||||
"duration_ms": 1,
|
||||
"status": "success",
|
||||
"response": "Implemented",
|
||||
"notes": "all good",
|
||||
"files_touched": ["src/lib.rs"],
|
||||
"node_visits": {"code": 2},
|
||||
"attempt": 1,
|
||||
"max_attempts": 1
|
||||
}
|
||||
}),
|
||||
&run_id,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
artifact_store
|
||||
.put(&run_id, &node, "src/lib.rs", b"fn main() {}")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let artifact_only_node = StageId::new("artifact-only", 7);
|
||||
artifact_store
|
||||
.put(&run_id, &artifact_only_node, "logs/output.txt", b"hello")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let output = tempfile::tempdir().unwrap();
|
||||
let file_count = export_run(&run, &artifact_store, output.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(file_count, 16);
|
||||
|
||||
let exported_run: RunProjection = read_json(&output.path().join("run.json"));
|
||||
assert_eq!(
|
||||
exported_run.spec.as_ref().map(|run| run.run_id),
|
||||
Some(run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run.start.as_ref().map(|start| start.run_id),
|
||||
Some(run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run.status,
|
||||
Some(RunStatus::Succeeded {
|
||||
reason: SuccessReason::Completed,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|checkpoint| checkpoint.current_node.as_str()),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|checkpoint| checkpoint.context_values.get("artifact")),
|
||||
Some(&serde_json::json!({"done": true}))
|
||||
);
|
||||
assert!(exported_run.conclusion.is_some());
|
||||
assert!(exported_run.sandbox.is_some());
|
||||
assert!(exported_run.retro.is_some());
|
||||
assert!(!output.path().join("start.json").exists());
|
||||
assert!(!output.path().join("status.json").exists());
|
||||
assert!(!output.path().join("checkpoint.json").exists());
|
||||
assert!(!output.path().join("sandbox.json").exists());
|
||||
assert!(!output.path().join("retro.json").exists());
|
||||
assert!(!output.path().join("conclusion.json").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("graph.fabro")).unwrap(),
|
||||
"digraph night_sky {}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/prompt.md")).unwrap(),
|
||||
"Plan the fix"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/response.md")).unwrap(),
|
||||
"Implemented"
|
||||
);
|
||||
let node_status: NodeStatusRecord =
|
||||
read_json(&output.path().join("stages/code@2/status.json"));
|
||||
assert_eq!(node_status.status, StageStatus::Success);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/stdout.log")).unwrap(),
|
||||
"stdout line"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/stderr.log")).unwrap(),
|
||||
""
|
||||
);
|
||||
assert!(
|
||||
output
|
||||
.path()
|
||||
.join("stages/code@2/script_invocation.json")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
output
|
||||
.path()
|
||||
.join("stages/code@2/script_timing.json")
|
||||
.is_file()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/retro/prompt.md")).unwrap(),
|
||||
"How did it go?"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("stages/retro/response.md")).unwrap(),
|
||||
"Smooth enough"
|
||||
);
|
||||
|
||||
let event_lines = std::fs::read_to_string(output.path().join("events.jsonl")).unwrap();
|
||||
let events: Vec<EventEnvelope> = event_lines
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(events.len(), 15);
|
||||
assert_eq!(events[0].seq, 1);
|
||||
assert_eq!(events.last().unwrap().seq, 15);
|
||||
|
||||
let first_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0004.json"));
|
||||
let second_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0005.json"));
|
||||
assert_eq!(first_checkpoint.current_node, "plan");
|
||||
assert_eq!(second_checkpoint.current_node, "code");
|
||||
assert_eq!(
|
||||
first_checkpoint.context_values.get("artifact"),
|
||||
Some(&serde_json::json!({"steps": 3}))
|
||||
);
|
||||
assert_eq!(
|
||||
second_checkpoint.context_values.get("artifact"),
|
||||
Some(&serde_json::json!({"done": true}))
|
||||
);
|
||||
assert!(!output.path().join("blobs").exists());
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(output.path().join("artifacts/code@2/src/lib.rs")).unwrap(),
|
||||
b"fn main() {}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(
|
||||
output
|
||||
.path()
|
||||
.join("artifacts/artifact-only@7/logs/output.txt")
|
||||
)
|
||||
.unwrap(),
|
||||
b"hello"
|
||||
);
|
||||
assert!(!output.path().join("stages/artifact-only@7").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_output_dir_rejects_non_empty_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
|
||||
|
|
@ -23,7 +23,11 @@ pub(crate) mod ssh;
|
|||
pub(crate) mod start;
|
||||
pub(crate) mod wait;
|
||||
|
||||
pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Result<()> {
|
||||
pub(crate) async fn dispatch(
|
||||
cmd: RunCommands,
|
||||
base_ctx: &CommandContext,
|
||||
worker_token: Option<String>,
|
||||
) -> Result<()> {
|
||||
let printer = base_ctx.printer();
|
||||
|
||||
match cmd {
|
||||
|
|
@ -73,19 +77,23 @@ pub(crate) async fn dispatch(cmd: RunCommands, base_ctx: &CommandContext) -> Res
|
|||
RunCommands::RunWorker(RunWorkerArgs {
|
||||
server,
|
||||
storage_dir,
|
||||
artifact_upload_token,
|
||||
run_dir,
|
||||
run_id,
|
||||
mode,
|
||||
}) => {
|
||||
runner::execute(
|
||||
let worker_token = worker_token
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow!("FABRO_WORKER_TOKEN is required for worker subprocess auth")
|
||||
})?;
|
||||
Box::pin(runner::execute(
|
||||
run_id,
|
||||
server,
|
||||
storage_dir,
|
||||
artifact_upload_token,
|
||||
run_dir,
|
||||
mode,
|
||||
)
|
||||
&worker_token,
|
||||
))
|
||||
.await
|
||||
}
|
||||
RunCommands::Diff(args) => diff::run(args, base_ctx).await,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::{
|
||||
ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId, WorkflowSettings,
|
||||
ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_vault::Vault;
|
||||
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
|
||||
|
|
@ -59,14 +60,15 @@ pub(crate) async fn execute(
|
|||
run_id: RunId,
|
||||
server: String,
|
||||
storage_dir: Option<PathBuf>,
|
||||
artifact_upload_token: Option<String>,
|
||||
run_dir: PathBuf,
|
||||
mode: RunWorkerMode,
|
||||
worker_token: &str,
|
||||
) -> Result<()> {
|
||||
let _ = fabro_proc::title_init();
|
||||
set_worker_title(&run_id, initial_worker_title_phase(mode));
|
||||
|
||||
let client = server_client::connect_server_target_direct(&server).await?;
|
||||
let target = server.parse::<fabro_client::ServerTarget>()?;
|
||||
let client = server_client::connect_server_target_with_bearer(&target, worker_token).await?;
|
||||
let run_store = HttpRunStore::connect(run_id, client.clone_for_reuse()).await?;
|
||||
let run_state = run_store
|
||||
.state()
|
||||
|
|
@ -79,7 +81,7 @@ pub(crate) async fn execute(
|
|||
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
|
||||
run_id,
|
||||
client.clone_for_reuse(),
|
||||
artifact_upload_token,
|
||||
worker_token.to_owned(),
|
||||
)));
|
||||
let interviewer = Arc::new(ControlInterviewer::new());
|
||||
let cancel_token = Arc::new(AtomicBool::new(false));
|
||||
|
|
@ -100,13 +102,16 @@ pub(crate) async fn execute(
|
|||
emitter: Arc::new(Emitter::new(run_id)),
|
||||
interviewer,
|
||||
run_store: run_store.clone(),
|
||||
event_sink: RunEventSink::fanout(vec![
|
||||
RunEventSink::backend(run_store),
|
||||
RunEventSink::callback(move |event| {
|
||||
update_worker_title_from_event(&event);
|
||||
async move { Ok(()) }
|
||||
}),
|
||||
]),
|
||||
event_sink: RunEventSink::map(
|
||||
stamp_system_worker,
|
||||
RunEventSink::fanout(vec![
|
||||
RunEventSink::backend(run_store),
|
||||
RunEventSink::callback(move |event| {
|
||||
update_worker_title_from_event(&event);
|
||||
async move { Ok(()) }
|
||||
}),
|
||||
]),
|
||||
),
|
||||
artifact_sink,
|
||||
run_control: Some(run_control),
|
||||
github_app,
|
||||
|
|
@ -243,22 +248,19 @@ async fn apply_worker_control_line(
|
|||
fn build_artifact_uploader(
|
||||
run_id: RunId,
|
||||
client: server_client::Client,
|
||||
artifact_upload_token: Option<String>,
|
||||
worker_token: String,
|
||||
) -> Arc<dyn StageArtifactUploader> {
|
||||
match artifact_upload_token {
|
||||
Some(token) => Arc::new(HttpArtifactUploader {
|
||||
run_id,
|
||||
client,
|
||||
bearer_token: token,
|
||||
}),
|
||||
None => Arc::new(MissingArtifactUploadTokenUploader { run_id }),
|
||||
}
|
||||
Arc::new(HttpArtifactUploader {
|
||||
run_id,
|
||||
client,
|
||||
worker_token,
|
||||
})
|
||||
}
|
||||
|
||||
struct HttpArtifactUploader {
|
||||
run_id: RunId,
|
||||
client: server_client::Client,
|
||||
bearer_token: String,
|
||||
worker_token: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -282,7 +284,7 @@ impl StageArtifactUploader for HttpArtifactUploader {
|
|||
stage_id,
|
||||
&artifact.path,
|
||||
&artifact_capture_dir.join(&artifact.path),
|
||||
&self.bearer_token,
|
||||
&self.worker_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -293,31 +295,12 @@ impl StageArtifactUploader for HttpArtifactUploader {
|
|||
stage_id,
|
||||
artifact_capture_dir,
|
||||
artifacts,
|
||||
&self.bearer_token,
|
||||
&self.worker_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct MissingArtifactUploadTokenUploader {
|
||||
run_id: RunId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StageArtifactUploader for MissingArtifactUploadTokenUploader {
|
||||
async fn upload_stage_artifacts(
|
||||
&self,
|
||||
_stage_id: &fabro_types::StageId,
|
||||
_artifact_capture_dir: &Path,
|
||||
_artifacts: &[ArtifactUpload],
|
||||
) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"run {} could not upload artifacts because the worker did not receive an artifact upload token",
|
||||
self.run_id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpRunStore {
|
||||
run_id: RunId,
|
||||
|
|
@ -504,6 +487,13 @@ fn update_worker_title_from_event(event: &RunEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
|
||||
if event.actor.is_none() {
|
||||
event.actor = Some(ActorRef::system_worker());
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
fn maybe_build_github_credentials(
|
||||
settings: &WorkflowSettings,
|
||||
vault: Option<&fabro_vault::Vault>,
|
||||
|
|
@ -588,6 +578,7 @@ mod tests {
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_auth::{AuthCredential, AuthDetails};
|
||||
use fabro_config::Storage;
|
||||
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
||||
|
|
@ -596,18 +587,36 @@ mod tests {
|
|||
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
|
||||
RunFailedProps, RunStatusTransitionProps,
|
||||
};
|
||||
use fabro_types::{EventBody, FailureReason, SuccessReason, fixtures};
|
||||
use fabro_types::{ActorRef, EventBody, FailureReason, SuccessReason, fixtures};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use fabro_workflow::artifact_upload::StageArtifactUploader;
|
||||
use fabro_workflow::event::RunEventSink;
|
||||
|
||||
use super::{
|
||||
MissingArtifactUploadTokenUploader, WorkerControlStreamEvent, WorkerTitlePhase,
|
||||
apply_worker_control_line, handle_worker_control_stream_events, initial_worker_title_phase,
|
||||
load_worker_vault, read_worker_control_stream_blocking, worker_title,
|
||||
WorkerControlStreamEvent, WorkerTitlePhase, apply_worker_control_line,
|
||||
handle_worker_control_stream_events, initial_worker_title_phase, load_worker_vault,
|
||||
read_worker_control_stream_blocking, stamp_system_worker, worker_title,
|
||||
worker_title_phase_for_event,
|
||||
};
|
||||
use crate::args::RunWorkerMode;
|
||||
|
||||
fn running_event(actor: Option<ActorRef>) -> fabro_types::RunEvent {
|
||||
fabro_types::RunEvent {
|
||||
id: "evt_1".to_string(),
|
||||
ts: Utc::now(),
|
||||
run_id: fixtures::RUN_1,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor,
|
||||
body: EventBody::RunRunning(RunStatusTransitionProps::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_title_uses_short_run_id_and_phase() {
|
||||
let short_id: String = fixtures::RUN_1.to_string().chars().take(12).collect();
|
||||
|
|
@ -700,24 +709,50 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_system_worker_fills_missing_actor_only() {
|
||||
let stamped = stamp_system_worker(running_event(None));
|
||||
|
||||
assert_eq!(stamped.actor, Some(ActorRef::system_worker()));
|
||||
|
||||
let existing_actor = ActorRef::user("octocat".to_string());
|
||||
let stamped = stamp_system_worker(running_event(Some(existing_actor.clone())));
|
||||
assert_eq!(stamped.actor, Some(existing_actor));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_artifact_upload_token_error_does_not_mention_removed_storage_mode() {
|
||||
let uploader = MissingArtifactUploadTokenUploader {
|
||||
run_id: fixtures::RUN_1,
|
||||
};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
let error = uploader
|
||||
.upload_stage_artifacts(&fabro_types::StageId::new("code", 2), temp.path(), &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("worker did not receive an artifact upload token")
|
||||
async fn worker_event_stamp_applies_to_all_fanout_sinks() {
|
||||
let first = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let second = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let first_events = Arc::clone(&first);
|
||||
let second_events = Arc::clone(&second);
|
||||
let sink = RunEventSink::map(
|
||||
stamp_system_worker,
|
||||
RunEventSink::fanout(vec![
|
||||
RunEventSink::callback(move |event| {
|
||||
let first_events = Arc::clone(&first_events);
|
||||
async move {
|
||||
first_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
RunEventSink::callback(move |event| {
|
||||
let second_events = Arc::clone(&second_events);
|
||||
async move {
|
||||
second_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert!(!error.to_string().contains("object-backed artifacts"));
|
||||
let event = running_event(None);
|
||||
|
||||
sink.write_run_event(&event).await.unwrap();
|
||||
|
||||
let first = first.lock().await;
|
||||
let second = second.lock().await;
|
||||
assert_eq!(first[0].actor, Some(ActorRef::system_worker()));
|
||||
assert_eq!(second[0].actor, Some(ActorRef::system_worker()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -72,12 +72,33 @@ async fn main() {
|
|||
std::process::exit(commands::render_graph::execute());
|
||||
}
|
||||
|
||||
// Capture the worker bearer token immediately and scrub it from the process
|
||||
// env before any subprocess can be spawned. Every descendant of the worker
|
||||
// (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore
|
||||
// inherits a process env that no longer contains FABRO_WORKER_TOKEN, so an
|
||||
// unscrubbed spawn site cannot leak it. The token flows to `runner::execute`
|
||||
// through an explicit function argument instead of the environment.
|
||||
let worker_token = if subcommand == Some("__run-worker") {
|
||||
let token = std::env::var("FABRO_WORKER_TOKEN").ok();
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Scrub the worker bearer from this process's env before any \
|
||||
child process is spawned, so no descendant can inherit it."
|
||||
)]
|
||||
{
|
||||
std::env::remove_var("FABRO_WORKER_TOKEN");
|
||||
}
|
||||
token
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tel_panic::install_panic_hook();
|
||||
fabro_telemetry::init_cli();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let (command_name, result) = Box::pin(main_inner()).await;
|
||||
let (command_name, result) = Box::pin(main_inner(worker_token)).await;
|
||||
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
let exit_code = result.as_ref().err().map_or(0, exit::exit_code_for);
|
||||
|
||||
|
|
@ -145,7 +166,7 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
async fn main_inner() -> (String, Result<()>) {
|
||||
async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
|
||||
let _ = default_provider().install_default();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -206,7 +227,7 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
commands::exec::execute(args, &base_ctx).await?;
|
||||
}
|
||||
Commands::RunCmd(cmd) => {
|
||||
Box::pin(commands::run::dispatch(cmd, &base_ctx)).await?;
|
||||
Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_token)).await?;
|
||||
}
|
||||
Commands::Preflight(args) => {
|
||||
commands::preflight::execute(args, &base_ctx).await?;
|
||||
|
|
@ -939,8 +960,6 @@ level = "warn"
|
|||
"__run-worker",
|
||||
"--server",
|
||||
"/tmp/fabro.sock",
|
||||
"--artifact-upload-token",
|
||||
"token-123",
|
||||
"--run-dir",
|
||||
"/tmp/run",
|
||||
"--run-id",
|
||||
|
|
@ -952,7 +971,6 @@ level = "warn"
|
|||
match *cli.command.unwrap() {
|
||||
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
||||
assert_eq!(args.server, "/tmp/fabro.sock");
|
||||
assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123"));
|
||||
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
||||
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
||||
assert!(matches!(args.mode, args::RunWorkerMode::Start));
|
||||
|
|
@ -979,7 +997,6 @@ level = "warn"
|
|||
match *cli.command.unwrap() {
|
||||
Commands::RunCmd(RunCommands::RunWorker(args)) => {
|
||||
assert_eq!(args.server, "http://127.0.0.1:3000");
|
||||
assert!(args.artifact_upload_token.is_none());
|
||||
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
|
||||
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
|
||||
assert!(matches!(args.mode, args::RunWorkerMode::Resume));
|
||||
|
|
|
|||
|
|
@ -127,15 +127,18 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
|
|||
});
|
||||
}
|
||||
|
||||
let working_directory =
|
||||
project::resolve_working_directory_from_run(&workflow_settings.run, &input.cwd);
|
||||
|
||||
let goal = resolve_manifest_goal(
|
||||
input.run_overrides.as_ref(),
|
||||
&workflow_settings,
|
||||
&root_source,
|
||||
&target_path,
|
||||
&input.cwd,
|
||||
&working_directory,
|
||||
)?;
|
||||
|
||||
let git = build_manifest_git(&input.cwd);
|
||||
let git = build_manifest_git(&working_directory);
|
||||
let args = input.args.filter(|args| !manifest_args_is_empty(args));
|
||||
|
||||
Ok(BuiltManifest {
|
||||
|
|
@ -420,14 +423,12 @@ fn resolve_manifest_goal(
|
|||
settings: &WorkflowSettings,
|
||||
root_source: &str,
|
||||
root_dot_path: &Path,
|
||||
cwd: &Path,
|
||||
working_directory: &Path,
|
||||
) -> Result<Option<types::ManifestGoal>> {
|
||||
let working_directory = project::resolve_working_directory_from_run(&settings.run, cwd);
|
||||
|
||||
// Precedence 1: CLI args (`--goal` / `--goal-file`). These are already
|
||||
// resolved to absolute paths by `overrides::goal_layer_from_args`.
|
||||
if let Some(run_overrides) = run_overrides {
|
||||
if let Some(resolved) = resolve_run_goal_from_layer(run_overrides, &working_directory)
|
||||
if let Some(resolved) = resolve_run_goal_from_layer(run_overrides, working_directory)
|
||||
.context("failed to resolve --goal-file contents")?
|
||||
{
|
||||
return Ok(Some(resolved_goal_to_manifest(resolved)));
|
||||
|
|
@ -437,7 +438,7 @@ fn resolve_manifest_goal(
|
|||
// Precedence 2: merged config `run.goal`. Config-sourced `goal.file`
|
||||
// paths were rewritten to absolute by `load_settings_path` at the
|
||||
// directory of the config file that declared them.
|
||||
if let Some(resolved) = resolve_run_goal_from_namespace(&settings.run, &working_directory)
|
||||
if let Some(resolved) = resolve_run_goal_from_namespace(&settings.run, working_directory)
|
||||
.context("failed to resolve run.goal.file contents")?
|
||||
{
|
||||
return Ok(Some(resolved_goal_to_manifest(resolved)));
|
||||
|
|
@ -489,11 +490,11 @@ fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
|
|||
}
|
||||
}
|
||||
|
||||
fn build_manifest_git(cwd: &Path) -> Option<types::ManifestGit> {
|
||||
let (origin_url, branch) = detect_repo_info(cwd).ok()?;
|
||||
fn build_manifest_git(repo_path: &Path) -> Option<types::ManifestGit> {
|
||||
let (origin_url, branch) = detect_repo_info(repo_path).ok()?;
|
||||
let branch = branch?;
|
||||
let sha = head_sha(cwd).ok()?;
|
||||
let clean = sync_status(cwd, "origin", Some(&branch)) != GitSyncStatus::Dirty;
|
||||
let sha = head_sha(repo_path).ok()?;
|
||||
let clean = sync_status(repo_path, "origin", Some(&branch)) != GitSyncStatus::Dirty;
|
||||
Some(types::ManifestGit {
|
||||
branch,
|
||||
clean,
|
||||
|
|
@ -786,4 +787,102 @@ file = "prompts/goal.md"
|
|||
let expected = workflow_dir.join("prompts").join("goal.md");
|
||||
assert_eq!(PathBuf::from(resolved), expected);
|
||||
}
|
||||
|
||||
/// When `[run] working_dir` points to a nested git repo, the manifest's
|
||||
/// `git.branch` and `git.origin_url` must come from that target repo, not
|
||||
/// from an enclosing workspace repo that happens to be the CLI's cwd.
|
||||
/// Regression test for https://github.com/fabro-sh/fabro/issues/159.
|
||||
#[test]
|
||||
fn build_manifest_git_follows_working_directory_into_nested_repo() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path();
|
||||
let target = workspace.join("repos").join("target");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
|
||||
init_git_repo(
|
||||
workspace,
|
||||
"workspace-branch",
|
||||
"https://github.com/example/workspace.git",
|
||||
);
|
||||
init_git_repo(
|
||||
&target,
|
||||
"target-branch",
|
||||
"https://github.com/example/target.git",
|
||||
);
|
||||
|
||||
let workflow_dir = workspace.join(".fabro/workflows/demo");
|
||||
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||||
std::fs::write(
|
||||
workspace.join(".fabro/project.toml"),
|
||||
r#"_version = 1
|
||||
|
||||
[run]
|
||||
working_dir = "repos/target"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.toml"),
|
||||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workflow_dir.join("workflow.fabro"),
|
||||
r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let built = build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
|
||||
cwd: workspace.to_path_buf(),
|
||||
run_overrides: None,
|
||||
cli_overrides: None,
|
||||
args: None,
|
||||
run_id: None,
|
||||
user_settings_path: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let git = built
|
||||
.manifest
|
||||
.git
|
||||
.expect("manifest git info should be detected");
|
||||
assert_eq!(git.branch, "target-branch");
|
||||
assert_eq!(git.origin_url, "https://github.com/example/target");
|
||||
}
|
||||
|
||||
fn init_git_repo(path: &Path, branch: &str, origin_url: &str) {
|
||||
use std::process::Command;
|
||||
let run = |args: &[&str]| {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?} failed: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
};
|
||||
run(&[
|
||||
"-c",
|
||||
&format!("init.defaultBranch={branch}"),
|
||||
"init",
|
||||
"--quiet",
|
||||
]);
|
||||
run(&[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"--quiet",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
run(&["remote", "add", "origin", origin_url]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,17 @@ pub(crate) async fn connect_server_target(target: &ServerTarget) -> Result<Clien
|
|||
connect_target_api_client_bundle(target).await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_server_target_direct(target: &str) -> Result<Client> {
|
||||
let target = target.parse::<ServerTarget>()?;
|
||||
connect_server_target(&target).await
|
||||
pub(crate) async fn connect_server_target_with_bearer(
|
||||
target: &ServerTarget,
|
||||
bearer: &str,
|
||||
) -> Result<Client> {
|
||||
build_client(
|
||||
target.clone(),
|
||||
Some(Credential::Worker(bearer.to_owned())),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_server_with_settings(
|
||||
|
|
@ -380,6 +388,8 @@ async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<(
|
|||
mod tests {
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_client::{AuthEntry, StoredSubject};
|
||||
use httpmock::Method::{GET, POST};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -545,6 +555,87 @@ mod tests {
|
|||
assert!(local_dev_token_fallback(&target));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_server_target_with_bearer_sends_worker_bearer_token() {
|
||||
let server = httpmock::MockServer::start();
|
||||
let info_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/system/info")
|
||||
.header("authorization", "Bearer worker-token");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"version": "1.2.3",
|
||||
"git_sha": "abcdef0",
|
||||
"build_date": "2026-04-20",
|
||||
"profile": "release",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"storage_dir": "/tmp/fabro-worker-auth",
|
||||
"storage_engine": "slatedb",
|
||||
"runs": { "total": 0, "active": 0 },
|
||||
"uptime_secs": 42
|
||||
}));
|
||||
});
|
||||
|
||||
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
||||
let client = connect_server_target_with_bearer(&target, "worker-token")
|
||||
.await
|
||||
.unwrap();
|
||||
let info = client.get_system_info().await.unwrap();
|
||||
|
||||
assert_eq!(info.version.as_deref(), Some("1.2.3"));
|
||||
info_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_server_target_with_bearer_does_not_attempt_oauth_refresh() {
|
||||
let server = httpmock::MockServer::start();
|
||||
let info_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/system/info")
|
||||
.header("authorization", "Bearer worker-token");
|
||||
then.status(401)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"errors": [{
|
||||
"status": "401",
|
||||
"title": "Unauthorized",
|
||||
"detail": "Access token expired.",
|
||||
"code": "access_token_expired"
|
||||
}]
|
||||
}));
|
||||
});
|
||||
let refresh_mock = server.mock(|when, then| {
|
||||
when.method(POST).path("/auth/cli/refresh");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(json!({
|
||||
"access_token": "unused",
|
||||
"access_token_expires_at": (Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
|
||||
"refresh_token": "unused",
|
||||
"refresh_token_expires_at": (Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
|
||||
"subject": {
|
||||
"idp_issuer": "https://github.com",
|
||||
"idp_subject": "12345",
|
||||
"login": "octocat",
|
||||
"name": "Octo Cat",
|
||||
"email": "octocat@example.com"
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
||||
let client = connect_server_target_with_bearer(&target, "worker-token")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = client.get_system_info().await.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Access token expired"));
|
||||
info_mock.assert();
|
||||
assert_eq!(refresh_mock.calls(), 0);
|
||||
}
|
||||
|
||||
fn oauth_entry(
|
||||
access_token_expires_at: chrono::DateTime<chrono::Utc>,
|
||||
refresh_token_expires_at: chrono::DateTime<chrono::Utc>,
|
||||
|
|
|
|||
|
|
@ -668,6 +668,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "run.starting",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {},
|
||||
|
|
@ -675,6 +680,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "sandbox.initializing",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
@ -684,6 +694,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "sandbox.ready",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
@ -694,6 +709,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "sandbox.initialized",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
@ -704,6 +724,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "run.started",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
@ -714,6 +739,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "run.running",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {},
|
||||
|
|
@ -721,6 +751,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "stage.started",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "start",
|
||||
|
|
@ -736,6 +771,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "stage.completed",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "start",
|
||||
|
|
@ -764,6 +804,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "edge.selected",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
@ -777,6 +822,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "checkpoint.completed",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "start",
|
||||
|
|
@ -815,6 +865,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "stage.started",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "approve",
|
||||
|
|
@ -830,6 +885,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "interview.started",
|
||||
"id": "[EVENT_ID]",
|
||||
"node_id": "approve",
|
||||
|
|
@ -856,6 +916,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"ts": "[TIMESTAMP]"
|
||||
},
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "run.blocked",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ fn logs_completed_run_reads_store_without_progress_jsonl() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
{"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
----- stderr -----
|
||||
"#);
|
||||
}
|
||||
|
|
@ -165,8 +165,8 @@ fn logs_tail_limits_output() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
{"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
----- stderr -----
|
||||
"#);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ mod upgrade;
|
|||
mod validate;
|
||||
mod version;
|
||||
mod wait;
|
||||
mod worker_auth;
|
||||
mod workflow;
|
||||
mod workflow_create;
|
||||
mod workflow_list;
|
||||
|
|
|
|||
|
|
@ -832,6 +832,11 @@ fn dry_run_persists_event_history_in_store() {
|
|||
.expect("tail logs should include the latest event");
|
||||
fabro_json_snapshot!(context, &live_content, @r#"
|
||||
{
|
||||
"actor": {
|
||||
"display": "system:worker",
|
||||
"id": "worker",
|
||||
"kind": "system"
|
||||
},
|
||||
"event": "sandbox.cleanup.completed",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,18 @@
|
|||
)]
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Child, ExitStatus, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_config::{Storage, envfile};
|
||||
use fabro_store::EventEnvelope;
|
||||
use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context};
|
||||
use fabro_types::{EventBody, FailureReason, RunEvent, StageId};
|
||||
use hkdf::Hkdf;
|
||||
use httpmock::MockServer;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::support::{
|
||||
find_run_dir, local_dev_token, output_stderr, run_events, run_state, server_endpoint,
|
||||
|
|
@ -25,6 +30,9 @@ use crate::support::{fabro_json_snapshot, unique_run_id};
|
|||
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const LEAKED_WORKER_PARENT_TOKEN: &str = "leak-worker-parent-token";
|
||||
const LEAKED_NEW_RELIC_LICENSE: &str = "leak-new-relic-license";
|
||||
const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
|
||||
const WORKER_TOKEN_SCOPE: &str = "run:worker";
|
||||
const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
|
||||
fn auth_context() -> fabro_test::TestContext {
|
||||
let context = test_context!();
|
||||
|
|
@ -52,6 +60,48 @@ fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) {
|
|||
)));
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct WorkerTokenClaims {
|
||||
iss: String,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
run_id: String,
|
||||
scope: String,
|
||||
jti: String,
|
||||
}
|
||||
|
||||
fn worker_token_for_run(storage_dir: &Path, run_id: &str) -> String {
|
||||
let runtime_directory = Storage::new(storage_dir).runtime_directory();
|
||||
let session_secret = envfile::read_env_file(&runtime_directory.env_path())
|
||||
.expect("server env should load")
|
||||
.get("SESSION_SECRET")
|
||||
.cloned()
|
||||
.expect("server env should include SESSION_SECRET");
|
||||
let hkdf = Hkdf::<Sha256>::new(None, session_secret.as_bytes());
|
||||
let mut key = [0_u8; 32];
|
||||
hkdf.expand(b"fabro-worker-jwt-v1", &mut key)
|
||||
.expect("worker jwt hkdf output should fit");
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let claims = WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: now,
|
||||
exp: now + WORKER_TOKEN_TTL_SECS,
|
||||
run_id: run_id.to_string(),
|
||||
scope: WORKER_TOKEN_SCOPE.to_string(),
|
||||
jti: format!("{:032x}", rand::random::<u128>()),
|
||||
};
|
||||
|
||||
jsonwebtoken::encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&key),
|
||||
)
|
||||
.expect("worker token should encode")
|
||||
}
|
||||
|
||||
fn spawn_worker_process(
|
||||
context: &fabro_test::TestContext,
|
||||
server: &str,
|
||||
|
|
@ -62,9 +112,10 @@ fn spawn_worker_process(
|
|||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut cmd, &context.home_dir);
|
||||
cmd.current_dir(&context.temp_dir);
|
||||
if let Some(token) = local_dev_token(&context.storage_dir) {
|
||||
cmd.env("FABRO_DEV_TOKEN", token);
|
||||
}
|
||||
cmd.env(
|
||||
"FABRO_WORKER_TOKEN",
|
||||
worker_token_for_run(&context.storage_dir, run_id),
|
||||
);
|
||||
cmd.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
|
|
@ -120,11 +171,12 @@ fn child_output(mut child: Child, status: ExitStatus) -> Output {
|
|||
}
|
||||
}
|
||||
|
||||
fn worker_command(context: &fabro_test::TestContext) -> assert_cmd::Command {
|
||||
fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd::Command {
|
||||
let mut cmd = context.command();
|
||||
if let Some(token) = local_dev_token(&context.storage_dir) {
|
||||
cmd.env("FABRO_DEV_TOKEN", token);
|
||||
}
|
||||
cmd.env(
|
||||
"FABRO_WORKER_TOKEN",
|
||||
worker_token_for_run(&context.storage_dir, run_id),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +184,7 @@ fn assert_no_worker_env_leak(scope: &str, content: &str) {
|
|||
for needle in [
|
||||
"MY_API_TOKEN=",
|
||||
"NEW_RELIC_LICENSE_KEY=",
|
||||
"FABRO_WORKER_TOKEN=",
|
||||
LEAKED_WORKER_PARENT_TOKEN,
|
||||
LEAKED_NEW_RELIC_LICENSE,
|
||||
] {
|
||||
|
|
@ -190,16 +243,46 @@ fn help() {
|
|||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--run-dir <RUN_DIR> Run scratch directory
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--run-id <RUN_ID> Run ID
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
--mode <MODE> Worker mode [possible values: start, resume]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_requires_fabro_worker_token_env() {
|
||||
let context = auth_context();
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
let run_id = unique_run_id();
|
||||
let output = context
|
||||
.command()
|
||||
.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
"http://127.0.0.1:32276",
|
||||
"--run-dir",
|
||||
run_dir.path().to_str().unwrap(),
|
||||
"--run-id",
|
||||
&run_id,
|
||||
"--mode",
|
||||
"start",
|
||||
])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.output()
|
||||
.expect("worker should execute");
|
||||
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
output_stderr(&output).contains("FABRO_WORKER_TOKEN"),
|
||||
"{}",
|
||||
output_stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_uses_cached_graph_after_source_deleted() {
|
||||
let context = auth_context();
|
||||
|
|
@ -234,7 +317,7 @@ digraph CachedGraph {
|
|||
let server = server_target(&context.storage_dir);
|
||||
std::fs::remove_file(&workflow_path).unwrap();
|
||||
|
||||
let output = worker_command(&context)
|
||||
let output = worker_command(&context, run_id.as_str())
|
||||
.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
|
|
@ -302,7 +385,7 @@ digraph GitHubApp {
|
|||
context.write_home(".fabro/settings.toml", "_version = 1\n");
|
||||
|
||||
let server = server_target(&context.storage_dir);
|
||||
let mut cmd = worker_command(&context);
|
||||
let mut cmd = worker_command(&context, run_id.as_str());
|
||||
cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%");
|
||||
cmd.args([
|
||||
"__run-worker",
|
||||
|
|
@ -352,7 +435,7 @@ digraph DetachedStoreOnly {
|
|||
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
let server = server_target(&context.storage_dir);
|
||||
let output = worker_command(&context)
|
||||
let output = worker_command(&context, run_id.as_str())
|
||||
.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
|
|
@ -425,7 +508,7 @@ methods = ["dev-token"]
|
|||
graph [goal="Verify worker subprocess env isolation", default_max_retries=0]
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
probe [shape=parallelogram, label="Probe", script="echo probe-ran; for key in $(printf 'MY%s NEW%s' '_API_TOKEN' '_RELIC_LICENSE_KEY'); do value=$(printenv \"$key\" || true); if [ -n \"$value\" ]; then echo \"$key=$value\"; fi; done"]
|
||||
probe [shape=parallelogram, label="Probe", script="echo probe-ran; for key in $(printf 'MY%s NEW%s FABRO%s' '_API_TOKEN' '_RELIC_LICENSE_KEY' '_WORKER_TOKEN'); do value=$(printenv \"$key\" || true); if [ -n \"$value\" ]; then echo \"$key=$value\"; fi; done"]
|
||||
start -> probe -> exit
|
||||
}
|
||||
"#,
|
||||
|
|
@ -550,7 +633,7 @@ digraph Test {
|
|||
}
|
||||
"#);
|
||||
|
||||
let mut cmd = worker_command(&context);
|
||||
let mut cmd = worker_command(&context, &run_id);
|
||||
cmd.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
|
|
@ -628,8 +711,7 @@ fn runner_reports_missing_run_spec_without_prefetching_events() {
|
|||
.body(r#"{"data":[],"meta":{"has_more":false}}"#);
|
||||
});
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
let output = worker_command(&context, &run_id)
|
||||
.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
|
|
@ -819,8 +901,8 @@ retros = true
|
|||
.position(|event| matches!(&event.body, EventBody::RetroCompleted(_)))
|
||||
.expect("retro.completed should be present");
|
||||
assert!(
|
||||
run_completed_index < retro_completed_index,
|
||||
"retro should still run after run.completed"
|
||||
retro_completed_index < run_completed_index,
|
||||
"retro.completed must precede run.completed"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
470
lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs
Normal file
470
lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "These worker-auth regressions start a real server subprocess, write isolated auth fixtures, and spawn the compiled fabro binary."
|
||||
)]
|
||||
#![expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "These regressions intentionally own Child processes to exercise the real server-dispatched worker path."
|
||||
)]
|
||||
#![expect(
|
||||
clippy::unwrap_used,
|
||||
reason = "Integration-test setup for real-subprocess auth harness; panic-on-failure is the desired behavior."
|
||||
)]
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_client::{AuthEntry, AuthStore, ServerTarget, StoredSubject};
|
||||
use fabro_config::{Storage, envfile};
|
||||
use fabro_store::EventEnvelope;
|
||||
use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context};
|
||||
use hkdf::Hkdf;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::support::{find_run_dir, output_stderr, output_stdout};
|
||||
use crate::support::{
|
||||
TEST_SESSION_SECRET, issue_test_github_jwt, parse_event_envelopes, unique_run_id,
|
||||
};
|
||||
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const TEST_GITHUB_CLIENT_SECRET: &str = "github-client-secret";
|
||||
const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
|
||||
const WORKER_TOKEN_SCOPE: &str = "run:worker";
|
||||
const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
|
||||
struct RunningGithubOnlyServer {
|
||||
child: Option<Child>,
|
||||
home_root: tempfile::TempDir,
|
||||
worker_home: PathBuf,
|
||||
_storage_root: tempfile::TempDir,
|
||||
storage_dir: PathBuf,
|
||||
api_base_url: String,
|
||||
}
|
||||
|
||||
impl RunningGithubOnlyServer {
|
||||
async fn start() -> Self {
|
||||
let home_root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let worker_home = home_root.path().join("worker-home");
|
||||
std::fs::create_dir_all(&worker_home).unwrap();
|
||||
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let port = reserve_port();
|
||||
let api_base_url = format!("http://127.0.0.1:{port}");
|
||||
let config_path = home_root.path().join("settings.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
r#"_version = 1
|
||||
|
||||
[server.web]
|
||||
enabled = true
|
||||
url = "{api_base_url}"
|
||||
|
||||
[server.auth]
|
||||
methods = ["github"]
|
||||
|
||||
[server.auth.github]
|
||||
allowed_usernames = ["octocat"]
|
||||
|
||||
[server.integrations.github]
|
||||
client_id = "github-client-id"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
envfile::merge_env_file(
|
||||
&Storage::new(&storage_dir).runtime_directory().env_path(),
|
||||
[
|
||||
("SESSION_SECRET", TEST_SESSION_SECRET),
|
||||
("GITHUB_APP_CLIENT_SECRET", TEST_GITHUB_CLIENT_SECRET),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_root.path());
|
||||
cmd.env("FABRO_HOME", &worker_home);
|
||||
cmd.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(format!("127.0.0.1:{port}"))
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("github-only server should spawn");
|
||||
wait_for_http_ready(&api_base_url, &mut child).await;
|
||||
|
||||
Self {
|
||||
child: Some(child),
|
||||
home_root,
|
||||
worker_home,
|
||||
_storage_root: storage_root,
|
||||
storage_dir,
|
||||
api_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn target(&self) -> String {
|
||||
format!("{}/api/v1", self.api_base_url)
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
let mut stop = Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut stop, self.home_root.path());
|
||||
stop.env("FABRO_HOME", &self.worker_home);
|
||||
stop.args(["server", "stop"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&self.storage_dir);
|
||||
let output = stop.output().expect("server stop should run");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"server stop failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let output = self
|
||||
.child
|
||||
.take()
|
||||
.expect("server child should still be present")
|
||||
.wait_with_output()
|
||||
.expect("server output should be readable");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"github-only server exited unsuccessfully\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningGithubOnlyServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.child.as_mut() {
|
||||
if child.try_wait().ok().flatten().is_none() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct WorkerTokenClaims {
|
||||
iss: String,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
run_id: String,
|
||||
scope: String,
|
||||
jti: String,
|
||||
}
|
||||
|
||||
fn reserve_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
fn write_submitter_auth(home_dir: &Path, target: &str, access_token: &str) {
|
||||
let auth_store = AuthStore::new(home_dir.join(".fabro").join("auth.json"));
|
||||
let target = ServerTarget::http_url(target).unwrap();
|
||||
let now = Utc::now();
|
||||
auth_store
|
||||
.put(&target, AuthEntry {
|
||||
access_token: access_token.to_string(),
|
||||
access_token_expires_at: now + ChronoDuration::minutes(10),
|
||||
refresh_token: "refresh-unused".to_string(),
|
||||
refresh_token_expires_at: now + ChronoDuration::days(30),
|
||||
subject: StoredSubject {
|
||||
idp_issuer: "https://github.com".to_string(),
|
||||
idp_subject: "12345".to_string(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
},
|
||||
logged_in_at: now,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_probe_workflow(path: &Path) {
|
||||
std::fs::write(
|
||||
path,
|
||||
r#"digraph WorkerAuthProbe {
|
||||
graph [goal="Verify github-only worker auth", default_max_retries=0]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
probe [shape=parallelogram, script="printf worker-auth-ok"]
|
||||
start -> probe -> exit
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn issue_worker_token_for_run(storage_dir: &Path, run_id: &str) -> String {
|
||||
let runtime_directory = Storage::new(storage_dir).runtime_directory();
|
||||
let session_secret = envfile::read_env_file(&runtime_directory.env_path())
|
||||
.expect("server env should load")
|
||||
.get("SESSION_SECRET")
|
||||
.cloned()
|
||||
.expect("server env should include SESSION_SECRET");
|
||||
let hkdf = Hkdf::<Sha256>::new(None, session_secret.as_bytes());
|
||||
let mut key = [0_u8; 32];
|
||||
hkdf.expand(b"fabro-worker-jwt-v1", &mut key)
|
||||
.expect("worker jwt hkdf output should fit");
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let claims = WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: now,
|
||||
exp: now + WORKER_TOKEN_TTL_SECS,
|
||||
run_id: run_id.to_string(),
|
||||
scope: WORKER_TOKEN_SCOPE.to_string(),
|
||||
jti: format!("{:032x}", rand::random::<u128>()),
|
||||
};
|
||||
|
||||
jsonwebtoken::encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&key),
|
||||
)
|
||||
.expect("worker token should encode")
|
||||
}
|
||||
|
||||
fn wait_for_run_dir(storage_dir: &Path, run_id: &str) -> PathBuf {
|
||||
let deadline = Instant::now() + COMMAND_TIMEOUT;
|
||||
loop {
|
||||
if let Some(run_dir) = find_run_dir(storage_dir, run_id) {
|
||||
return run_dir;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for run dir for {run_id}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_http_ready(base_url: &str, child: &mut Child) {
|
||||
let client = fabro_test::test_http_client();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match client.get(format!("{base_url}/health")).send().await {
|
||||
Ok(response) if response.status().is_success() => return,
|
||||
Ok(_) | Err(_) if Instant::now() < deadline => {
|
||||
if let Some(status) = child.try_wait().expect("server process should poll") {
|
||||
let mut stderr = Vec::new();
|
||||
if let Some(stderr_pipe) = child.stderr.as_mut() {
|
||||
stderr_pipe
|
||||
.read_to_end(&mut stderr)
|
||||
.expect("server stderr should be readable");
|
||||
}
|
||||
panic!(
|
||||
"github-only server exited before becoming ready with status {status}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&stderr)
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
Ok(response) => panic!("server at {base_url} was not ready: {}", response.status()),
|
||||
Err(err) => panic!("server at {base_url} was not ready: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec<EventEnvelope> {
|
||||
let response = fabro_test::test_http_client()
|
||||
.get(format!("{api_base_url}/api/v1/runs/{run_id}/events"))
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("event request should succeed");
|
||||
let body: serde_json::Value = expect_reqwest_json(
|
||||
response,
|
||||
fabro_http::StatusCode::OK,
|
||||
format!("GET /api/v1/runs/{run_id}/events"),
|
||||
)
|
||||
.await;
|
||||
parse_event_envelopes(&body)
|
||||
}
|
||||
|
||||
async fn wait_for_completed_events(
|
||||
api_base_url: &str,
|
||||
run_id: &str,
|
||||
access_token: &str,
|
||||
) -> Vec<EventEnvelope> {
|
||||
let deadline = Instant::now() + COMMAND_TIMEOUT;
|
||||
loop {
|
||||
let events = run_events(api_base_url, run_id, access_token).await;
|
||||
if events
|
||||
.iter()
|
||||
.any(|event| event.event.event_name() == "run.completed")
|
||||
{
|
||||
return events;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for run.completed for {run_id}"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn github_only_server_dispatched_worker_succeeds_without_worker_auth_store() {
|
||||
let context = test_context!();
|
||||
let server = RunningGithubOnlyServer::start().await;
|
||||
let target = server.target();
|
||||
let access_token = issue_test_github_jwt(&server.api_base_url);
|
||||
write_submitter_auth(&context.home_dir, &target, &access_token);
|
||||
assert!(!server.worker_home.join("auth.json").exists());
|
||||
assert!(!server.worker_home.join("auth.lock").exists());
|
||||
|
||||
let workflow = context.temp_dir.join("worker-auth.fabro");
|
||||
write_probe_workflow(&workflow);
|
||||
let run_id = unique_run_id();
|
||||
let output = context
|
||||
.run_cmd()
|
||||
.args([
|
||||
"--server",
|
||||
&target,
|
||||
"--run-id",
|
||||
&run_id,
|
||||
"--detach",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
workflow.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("detached run should execute");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"github-only detached run failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output_stdout(&output).trim(), run_id);
|
||||
|
||||
let _run_dir = wait_for_run_dir(&server.storage_dir, &run_id);
|
||||
let events = wait_for_completed_events(&server.api_base_url, &run_id, &access_token).await;
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event
|
||||
.event
|
||||
.actor
|
||||
.as_ref()
|
||||
.and_then(|actor| actor.display.as_deref())
|
||||
== Some("system:worker")
|
||||
}));
|
||||
assert!(!server.worker_home.join("auth.json").exists());
|
||||
assert!(!server.worker_home.join("auth.lock").exists());
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_rejects_bogus_worker_token_against_github_only_server() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let context = test_context!();
|
||||
let server = RunningGithubOnlyServer::start().await;
|
||||
let target = server.target();
|
||||
let access_token = issue_test_github_jwt(&server.api_base_url);
|
||||
write_submitter_auth(&context.home_dir, &target, &access_token);
|
||||
|
||||
let workflow = context.temp_dir.join("worker-auth-negative.fabro");
|
||||
write_probe_workflow(&workflow);
|
||||
let run_id = unique_run_id();
|
||||
let create_output = context
|
||||
.create_cmd()
|
||||
.args([
|
||||
"--server",
|
||||
&target,
|
||||
"--run-id",
|
||||
&run_id,
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
workflow.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("remote create should execute");
|
||||
|
||||
assert!(
|
||||
create_output.status.success(),
|
||||
"github-only create failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&create_output.stdout),
|
||||
String::from_utf8_lossy(&create_output.stderr)
|
||||
);
|
||||
assert_eq!(output_stdout(&create_output).trim(), run_id);
|
||||
|
||||
let run_dir = wait_for_run_dir(&server.storage_dir, &run_id);
|
||||
let worker_root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let worker_home = worker_root.path().join("fabro-home");
|
||||
std::fs::create_dir_all(&worker_home).unwrap();
|
||||
let auth_file = worker_root.path().join("missing").join("auth.json");
|
||||
let bogus_token = issue_worker_token_for_run(&server.storage_dir, &unique_run_id());
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, worker_root.path());
|
||||
cmd.env("FABRO_HOME", &worker_home);
|
||||
cmd.env("FABRO_AUTH_FILE", &auth_file);
|
||||
cmd.env("FABRO_WORKER_TOKEN", bogus_token);
|
||||
cmd.args([
|
||||
"__run-worker",
|
||||
"--server",
|
||||
&target,
|
||||
"--run-dir",
|
||||
run_dir.to_str().unwrap(),
|
||||
"--run-id",
|
||||
&run_id,
|
||||
"--mode",
|
||||
"start",
|
||||
]);
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
let output = cmd.output().expect("worker should execute");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"worker should fail with a bogus token\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = output_stderr(&output);
|
||||
assert!(
|
||||
stderr.contains("403")
|
||||
|| stderr.contains("Forbidden")
|
||||
|| stderr.contains("Authentication required")
|
||||
|| stderr.contains("Access denied"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(!auth_file.exists());
|
||||
assert!(!auth_file.with_extension("lock").exists());
|
||||
|
||||
server.shutdown();
|
||||
});
|
||||
}
|
||||
|
|
@ -26,19 +26,16 @@ use fabro_server::server::{
|
|||
create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env,
|
||||
};
|
||||
use fabro_test::{GitHubAppState, TestContext, apply_test_isolation};
|
||||
use fabro_types::RunAuthMethod;
|
||||
use hkdf::Hkdf;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinHandle;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::auth_tokens::{
|
||||
TEST_SESSION_SECRET, TestGithubJwtSubject, issue_expired_test_github_jwt,
|
||||
};
|
||||
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_SESSION_SECRET: &str =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
pub(crate) const TEST_DEV_TOKEN: &str =
|
||||
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
|
||||
|
|
@ -330,22 +327,6 @@ async fn record_request(
|
|||
next.run(req).await
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct TestJwtClaims {
|
||||
iss: String,
|
||||
aud: String,
|
||||
sub: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
jti: String,
|
||||
idp_issuer: String,
|
||||
idp_subject: String,
|
||||
login: String,
|
||||
name: String,
|
||||
email: String,
|
||||
auth_method: RunAuthMethod,
|
||||
}
|
||||
|
||||
async fn bind_listener() -> (TcpListener, String) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
|
|
@ -484,43 +465,15 @@ fn auth_store_path(context: &TestContext) -> std::path::PathBuf {
|
|||
}
|
||||
|
||||
fn expired_access_token(issuer: &str, subject: &serde_json::Map<String, Value>) -> String {
|
||||
let key = derived_jwt_key();
|
||||
let now = Utc::now();
|
||||
let claims = TestJwtClaims {
|
||||
iss: issuer.to_string(),
|
||||
aud: "fabro-cli".to_string(),
|
||||
sub: subject_value(subject, "idp_subject"),
|
||||
exp: (now - ChronoDuration::minutes(10))
|
||||
.timestamp()
|
||||
.try_into()
|
||||
.expect("expired timestamp should be positive"),
|
||||
iat: (now - ChronoDuration::minutes(20))
|
||||
.timestamp()
|
||||
.try_into()
|
||||
.expect("issued-at timestamp should be positive"),
|
||||
jti: Ulid::new().to_string(),
|
||||
issue_expired_test_github_jwt(issuer, TestGithubJwtSubject {
|
||||
idp_issuer: subject_value(subject, "idp_issuer"),
|
||||
idp_subject: subject_value(subject, "idp_subject"),
|
||||
login: subject_value(subject, "login"),
|
||||
name: subject_value(subject, "name"),
|
||||
email: subject_value(subject, "email"),
|
||||
auth_method: RunAuthMethod::Github,
|
||||
};
|
||||
|
||||
jsonwebtoken::encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&key),
|
||||
)
|
||||
.expect("expired JWT should encode")
|
||||
}
|
||||
|
||||
fn derived_jwt_key() -> [u8; 32] {
|
||||
let hkdf = Hkdf::<Sha256>::new(None, TEST_SESSION_SECRET.as_bytes());
|
||||
let mut key = [0_u8; 32];
|
||||
hkdf.expand(b"fabro-jwt-hs256-v1", &mut key)
|
||||
.expect("HKDF should derive the fixed-size JWT key");
|
||||
key
|
||||
avatar_url: String::new(),
|
||||
user_url: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_stderr_and_capture_url(
|
||||
|
|
|
|||
122
lib/crates/fabro-cli/tests/it/support/auth_tokens.rs
Normal file
122
lib/crates/fabro-cli/tests/it/support/auth_tokens.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_types::RunAuthMethod;
|
||||
use hkdf::Hkdf;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use sha2::Sha256;
|
||||
use ulid::Ulid;
|
||||
|
||||
pub(crate) const TEST_SESSION_SECRET: &str =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const JWT_AUDIENCE: &str = "fabro-cli";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TestGithubJwtSubject {
|
||||
pub(crate) idp_issuer: String,
|
||||
pub(crate) idp_subject: String,
|
||||
pub(crate) login: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) email: String,
|
||||
pub(crate) avatar_url: String,
|
||||
pub(crate) user_url: String,
|
||||
}
|
||||
|
||||
impl TestGithubJwtSubject {
|
||||
pub(crate) fn octocat() -> Self {
|
||||
Self {
|
||||
idp_issuer: "https://github.com".to_string(),
|
||||
idp_subject: "12345".to_string(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: "https://example.com/octocat.png".to_string(),
|
||||
user_url: "https://github.com/octocat".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct TestJwtClaims {
|
||||
iss: String,
|
||||
aud: String,
|
||||
sub: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
jti: String,
|
||||
idp_issuer: String,
|
||||
idp_subject: String,
|
||||
login: String,
|
||||
name: String,
|
||||
email: String,
|
||||
avatar_url: String,
|
||||
user_url: String,
|
||||
auth_method: RunAuthMethod,
|
||||
}
|
||||
|
||||
pub(crate) fn issue_test_github_jwt(issuer: &str) -> String {
|
||||
let now = Utc::now();
|
||||
issue_github_jwt(
|
||||
issuer,
|
||||
TestGithubJwtSubject::octocat(),
|
||||
now,
|
||||
now + ChronoDuration::minutes(10),
|
||||
format!("{:032x}", rand::random::<u128>()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn issue_expired_test_github_jwt(issuer: &str, subject: TestGithubJwtSubject) -> String {
|
||||
let now = Utc::now();
|
||||
issue_github_jwt(
|
||||
issuer,
|
||||
subject,
|
||||
now - ChronoDuration::minutes(20),
|
||||
now - ChronoDuration::minutes(10),
|
||||
Ulid::new().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn issue_github_jwt(
|
||||
issuer: &str,
|
||||
subject: TestGithubJwtSubject,
|
||||
issued_at: chrono::DateTime<Utc>,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
jti: String,
|
||||
) -> String {
|
||||
let key = derived_jwt_key();
|
||||
let claims = TestJwtClaims {
|
||||
iss: issuer.to_string(),
|
||||
aud: JWT_AUDIENCE.to_string(),
|
||||
sub: subject.idp_subject.clone(),
|
||||
exp: expires_at
|
||||
.timestamp()
|
||||
.try_into()
|
||||
.expect("expiration time should be positive"),
|
||||
iat: issued_at
|
||||
.timestamp()
|
||||
.try_into()
|
||||
.expect("issued-at time should be positive"),
|
||||
jti,
|
||||
idp_issuer: subject.idp_issuer,
|
||||
idp_subject: subject.idp_subject,
|
||||
login: subject.login,
|
||||
name: subject.name,
|
||||
email: subject.email,
|
||||
avatar_url: subject.avatar_url,
|
||||
user_url: subject.user_url,
|
||||
auth_method: RunAuthMethod::Github,
|
||||
};
|
||||
jsonwebtoken::encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&key),
|
||||
)
|
||||
.expect("test GitHub JWT should encode")
|
||||
}
|
||||
|
||||
fn derived_jwt_key() -> [u8; 32] {
|
||||
let hkdf = Hkdf::<Sha256>::new(None, TEST_SESSION_SECRET.as_bytes());
|
||||
let mut key = [0_u8; 32];
|
||||
hkdf.expand(b"fabro-jwt-hs256-v1", &mut key)
|
||||
.expect("HKDF should derive the fixed-size JWT key");
|
||||
key
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod auth_harness;
|
||||
mod auth_tokens;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use fabro_store::EventEnvelope;
|
||||
|
|
@ -50,6 +51,7 @@ pub(crate) use auth_harness::{
|
|||
RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token,
|
||||
no_redirect_browser_client, run_detached, saved_auth_entry,
|
||||
};
|
||||
pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt};
|
||||
pub(crate) use fabro_json_snapshot;
|
||||
|
||||
pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> {
|
||||
|
|
|
|||
|
|
@ -35,4 +35,5 @@ tracing.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
httpmock = "0.8"
|
||||
static_assertions = "1"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::AuthEntry;
|
|||
#[derive(Clone)]
|
||||
pub enum Credential {
|
||||
DevToken(String),
|
||||
Worker(String),
|
||||
OAuth(AuthEntry),
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ where
|
|||
impl Credential {
|
||||
pub fn bearer_token(&self) -> &str {
|
||||
match self {
|
||||
Self::DevToken(token) => token,
|
||||
Self::DevToken(token) | Self::Worker(token) => token,
|
||||
Self::OAuth(entry) => &entry.access_token,
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +35,29 @@ impl fmt::Debug for Credential {
|
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DevToken(_) => f.write_str("Credential::DevToken(<redacted>)"),
|
||||
Self::Worker(_) => f.write_str("Credential::Worker(<redacted>)"),
|
||||
Self::OAuth(_) => f.write_str("Credential::OAuth(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use static_assertions::assert_not_impl_any;
|
||||
|
||||
use super::Credential;
|
||||
|
||||
assert_not_impl_any!(Credential: std::fmt::Display);
|
||||
|
||||
#[test]
|
||||
fn worker_bearer_token_returns_inner_token() {
|
||||
let credential = Credential::Worker("worker-token".to_string());
|
||||
assert_eq!(credential.bearer_token(), "worker-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_debug_redacts_token() {
|
||||
let credential = Credential::Worker("worker-token".to_string());
|
||||
assert_eq!(format!("{credential:?}"), "Credential::Worker(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,9 @@ impl Sandbox for LocalSandbox {
|
|||
|
||||
if let Some(extra) = env_vars {
|
||||
for (k, v) in extra {
|
||||
filtered_env.push((k.clone(), v.clone()));
|
||||
if !Self::should_filter_env_var(k) {
|
||||
filtered_env.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -547,6 +549,7 @@ async fn sigterm_then_kill(child: &mut Child) {
|
|||
reason = "sandbox tests stage fixtures with sync std::fs writes/reads"
|
||||
)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -705,6 +708,24 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_command_filters_sensitive_explicit_env_vars() {
|
||||
let dir = temp_dir();
|
||||
let env = LocalSandbox::new(dir.clone());
|
||||
let extra = HashMap::from([
|
||||
("FABRO_WORKER_TOKEN".to_string(), "leaked".to_string()),
|
||||
("MY_VAR".to_string(), "ok".to_string()),
|
||||
]);
|
||||
let result = env
|
||||
.exec_command("env", 5000, None, Some(&extra), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.stdout.contains("FABRO_WORKER_TOKEN=leaked"));
|
||||
assert!(result.stdout.contains("MY_VAR=ok"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_filtering() {
|
||||
assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY"));
|
||||
|
|
@ -713,6 +734,8 @@ mod tests {
|
|||
assert!(LocalSandbox::should_filter_env_var("AWS_SECRET"));
|
||||
assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN"));
|
||||
assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL"));
|
||||
assert!(LocalSandbox::should_filter_env_var("FABRO_WORKER_TOKEN"));
|
||||
assert!(LocalSandbox::should_filter_env_var("SESSION_SECRET"));
|
||||
// Case insensitive
|
||||
assert!(LocalSandbox::should_filter_env_var("my_api_key"));
|
||||
assert!(LocalSandbox::should_filter_env_var("Some_Secret"));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use sha2::Sha256;
|
|||
|
||||
const COOKIE_KEY_INFO: &[u8] = b"fabro-cookie-v1";
|
||||
const JWT_KEY_INFO: &[u8] = b"fabro-jwt-hs256-v1";
|
||||
const WORKER_JWT_KEY_INFO: &[u8] = b"fabro-worker-jwt-v1";
|
||||
const MIN_MASTER_BYTES: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -42,6 +43,10 @@ pub(crate) fn derive_jwt_key(master: &[u8]) -> Result<JwtSigningKey, KeyDeriveEr
|
|||
Ok(JwtSigningKey(derive_bytes::<32>(master, JWT_KEY_INFO)?))
|
||||
}
|
||||
|
||||
pub(crate) fn derive_worker_jwt_key(master: &[u8]) -> Result<[u8; 32], KeyDeriveError> {
|
||||
derive_bytes::<32>(master, WORKER_JWT_KEY_INFO)
|
||||
}
|
||||
|
||||
fn derive_bytes<const N: usize>(master: &[u8], info: &[u8]) -> Result<[u8; N], KeyDeriveError> {
|
||||
validate_master(master)?;
|
||||
|
||||
|
|
@ -69,7 +74,7 @@ fn validate_master(master: &[u8]) -> Result<(), KeyDeriveError> {
|
|||
mod tests {
|
||||
use cookie::{Cookie, CookieJar};
|
||||
|
||||
use super::{KeyDeriveError, derive_cookie_key, derive_jwt_key};
|
||||
use super::{KeyDeriveError, derive_cookie_key, derive_jwt_key, derive_worker_jwt_key};
|
||||
|
||||
#[test]
|
||||
fn derives_same_cookie_key_for_same_master() {
|
||||
|
|
@ -93,6 +98,18 @@ mod tests {
|
|||
assert_eq!(jwt_key.as_bytes().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_different_worker_and_user_jwt_subkeys() {
|
||||
let master = [0x61; 32];
|
||||
|
||||
let user_key = derive_jwt_key(&master).expect("jwt derivation should succeed");
|
||||
let worker_key =
|
||||
derive_worker_jwt_key(&master).expect("worker jwt derivation should succeed");
|
||||
|
||||
assert_ne!(user_key.as_bytes(), worker_key);
|
||||
assert_eq!(worker_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_master_secret() {
|
||||
let err = derive_cookie_key(&[]).expect_err("empty secret should fail");
|
||||
|
|
@ -108,6 +125,21 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_derivation_rejects_empty_master_secret() {
|
||||
let err = derive_worker_jwt_key(&[]).expect_err("empty secret should fail");
|
||||
assert_eq!(err, KeyDeriveError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_derivation_rejects_short_master_secret() {
|
||||
let err = derive_worker_jwt_key(&[0x61; 31]).expect_err("short secret should fail");
|
||||
assert_eq!(err, KeyDeriveError::TooShort {
|
||||
got_bytes: 31,
|
||||
min_bytes: 32,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_cookie_key_round_trips_private_cookie() {
|
||||
let key = derive_cookie_key(&[0x61; 32]).expect("derivation should succeed");
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ pub(crate) use cli_flow::web_routes;
|
|||
pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken};
|
||||
pub use github_endpoints::GithubEndpoints;
|
||||
pub(crate) use jwt::{JwtError, JwtSubject, issue, verify};
|
||||
pub(crate) use keys::{JwtSigningKey, KeyDeriveError, derive_cookie_key, derive_jwt_key};
|
||||
pub(crate) use keys::{
|
||||
JwtSigningKey, KeyDeriveError, derive_cookie_key, derive_jwt_key, derive_worker_jwt_key,
|
||||
};
|
||||
pub(crate) use translate::{auth_translation_middleware, demo_routing_middleware};
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ where
|
|||
.unwrap_or_else(|| "fabro-server".to_string())
|
||||
}
|
||||
|
||||
fn session_secret_key_error(err: &KeyDeriveError) -> anyhow::Error {
|
||||
pub(crate) fn session_secret_key_error(err: &KeyDeriveError) -> anyhow::Error {
|
||||
match err {
|
||||
KeyDeriveError::Empty => {
|
||||
anyhow!(
|
||||
|
|
@ -182,7 +182,7 @@ fn config_allows_run_auth_method(config: &ConfiguredAuth, method: RunAuthMethod)
|
|||
}
|
||||
}
|
||||
|
||||
fn bearer_token(parts: &Parts) -> Option<Result<&str, ApiError>> {
|
||||
pub(crate) fn bearer_token(parts: &Parts) -> Option<Result<&str, ApiError>> {
|
||||
let value = parts.headers.get(header::AUTHORIZATION)?;
|
||||
let Ok(value) = value.to_str() else {
|
||||
return Some(Err(ApiError::unauthorized()));
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ mod spawn_env;
|
|||
mod startup;
|
||||
pub mod static_files;
|
||||
pub mod web_auth;
|
||||
mod worker_token;
|
||||
|
||||
pub use error::{ApiError, Error, Result};
|
||||
pub use server_secrets::process_env_snapshot;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ use tokio::sync::{Mutex, watch};
|
|||
use crate::error::ApiError;
|
||||
use crate::jwt_auth::AuthenticatedService;
|
||||
use crate::run_files_security::{RunFilesMetrics, is_sensitive};
|
||||
use crate::server::{AppState, parse_run_id_path_pub};
|
||||
use crate::server::{AppState, parse_run_id_path};
|
||||
|
||||
/// Per-file cap: 256 KiB OR 20k lines (whichever comes first).
|
||||
pub(crate) const PER_FILE_BYTES_CAP: u64 = 256 * 1024;
|
||||
|
|
@ -190,7 +190,7 @@ pub async fn list_run_files(
|
|||
Query(params): Query<ListRunFilesParams>,
|
||||
) -> Response {
|
||||
// 1. Parse run_id.
|
||||
let id = match parse_run_id_path_pub(&id) {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use axum::body::Body;
|
|||
#[cfg(test)]
|
||||
use axum::body::to_bytes;
|
||||
use axum::extract::{self as axum_extract, DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{HeaderMap, Method, StatusCode, header};
|
||||
use axum::middleware::{self};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
|
|
@ -40,7 +39,7 @@ pub use fabro_api::types::{
|
|||
};
|
||||
use fabro_auth::parse_credential_secret;
|
||||
use fabro_config::daemon::ServerDaemon;
|
||||
use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, Storage};
|
||||
use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, Storage, envfile};
|
||||
use fabro_interview::{
|
||||
Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope,
|
||||
};
|
||||
|
|
@ -66,7 +65,7 @@ use fabro_store::{
|
|||
use fabro_types::BlockedReason;
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::settings::server::{GithubIntegrationSettings, GithubIntegrationStrategy};
|
||||
use fabro_types::settings::{InterpString, RunNamespace, ServerAuthMethod};
|
||||
use fabro_types::settings::{InterpString, RunNamespace};
|
||||
use fabro_types::{
|
||||
ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId,
|
||||
RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance,
|
||||
|
|
@ -87,10 +86,7 @@ use fabro_workflow::run_lookup::{
|
|||
RunInfo, StatusFilter, filter_runs, scan_runs_with_summaries, scratch_base,
|
||||
};
|
||||
use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use object_store::memory::InMemory as MemoryObjectStore;
|
||||
use rand::TryRngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::fs;
|
||||
|
|
@ -114,15 +110,17 @@ use crate::github_webhooks::{
|
|||
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
|
||||
};
|
||||
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
|
||||
use crate::jwt_auth::{
|
||||
AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts,
|
||||
};
|
||||
use crate::jwt_auth::{self, AuthMode, AuthenticatedService, AuthenticatedSubject};
|
||||
use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight};
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
use crate::server_secrets::{
|
||||
LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message,
|
||||
};
|
||||
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
|
||||
use crate::worker_token::{
|
||||
AuthorizeRunBlob, AuthorizeRunScoped, AuthorizeStageArtifact, WorkerTokenKeys,
|
||||
issue_worker_token,
|
||||
};
|
||||
use crate::{demo, diagnostics, run_manifest, security_headers, static_files, web_auth};
|
||||
|
||||
pub(crate) type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
|
@ -281,9 +279,6 @@ const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
|
|||
const TERMINAL_DELETE_WORKER_GRACE: Duration = Duration::from_millis(50);
|
||||
const WORKER_CONTROL_QUEUE_CAPACITY: usize = 8;
|
||||
const WORKER_CONTROL_ENQUEUE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const ARTIFACT_UPLOAD_TOKEN_ISSUER: &str = "fabro-server-artifact-upload";
|
||||
const ARTIFACT_UPLOAD_TOKEN_SCOPE: &str = "stage_artifacts:upload";
|
||||
const ARTIFACT_UPLOAD_TOKEN_TTL_SECS: u64 = 24 * 60 * 60;
|
||||
const MAX_SINGLE_ARTIFACT_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const MAX_MULTIPART_ARTIFACTS: usize = 100;
|
||||
const RENDER_ERROR_PREFIX: &[u8] = b"RENDER_ERROR:";
|
||||
|
|
@ -306,22 +301,6 @@ enum RenderSubprocessError {
|
|||
const MAX_MULTIPART_REQUEST_BYTES: u64 = 50 * 1024 * 1024;
|
||||
const MAX_MULTIPART_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ArtifactUploadTokenKeys {
|
||||
encoding: Arc<EncodingKey>,
|
||||
decoding: Arc<DecodingKey>,
|
||||
validation: Arc<Validation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct ArtifactUploadClaims {
|
||||
iss: String,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
run_id: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct ArtifactBatchUploadManifest {
|
||||
entries: Vec<ArtifactBatchUploadEntry>,
|
||||
|
|
@ -559,7 +538,7 @@ pub struct AppState {
|
|||
aggregate_billing: Mutex<BillingAccumulator>,
|
||||
store: Arc<Database>,
|
||||
artifact_store: ArtifactStore,
|
||||
artifact_upload_tokens: ArtifactUploadTokenKeys,
|
||||
worker_tokens: WorkerTokenKeys,
|
||||
started_at: Instant,
|
||||
max_concurrent_runs: usize,
|
||||
scheduler_notify: Notify,
|
||||
|
|
@ -714,6 +693,10 @@ impl AppState {
|
|||
self.server_secrets.get(name)
|
||||
}
|
||||
|
||||
pub(crate) fn worker_token_keys(&self) -> &WorkerTokenKeys {
|
||||
&self.worker_tokens
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result<String> {
|
||||
value
|
||||
.resolve(|name| (self.env_lookup)(name))
|
||||
|
|
@ -770,30 +753,6 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
fn issue_artifact_upload_token(&self, run_id: &RunId) -> Result<String, ApiError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_secs());
|
||||
let claims = ArtifactUploadClaims {
|
||||
iss: ARTIFACT_UPLOAD_TOKEN_ISSUER.to_string(),
|
||||
iat: now,
|
||||
exp: now + ARTIFACT_UPLOAD_TOKEN_TTL_SECS,
|
||||
run_id: run_id.to_string(),
|
||||
scope: ARTIFACT_UPLOAD_TOKEN_SCOPE.to_string(),
|
||||
};
|
||||
jsonwebtoken::encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&self.artifact_upload_tokens.encoding,
|
||||
)
|
||||
.map_err(|err| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to sign artifact upload token: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn begin_shutdown(&self) {
|
||||
self.shutting_down.store(true, Ordering::Relaxed);
|
||||
self.scheduler_notify.notify_waiters();
|
||||
|
|
@ -833,65 +792,6 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
fn artifact_upload_token_keys() -> ArtifactUploadTokenKeys {
|
||||
let mut secret = [0_u8; 32];
|
||||
OsRng.try_fill_bytes(&mut secret).expect("OS RNG");
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
|
||||
validation.set_issuer(&[ARTIFACT_UPLOAD_TOKEN_ISSUER]);
|
||||
|
||||
ArtifactUploadTokenKeys {
|
||||
encoding: Arc::new(EncodingKey::from_secret(&secret)),
|
||||
decoding: Arc::new(DecodingKey::from_secret(&secret)),
|
||||
validation: Arc::new(validation),
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_authorize_artifact_upload_token(
|
||||
parts: &Parts,
|
||||
run_id: &RunId,
|
||||
keys: &ArtifactUploadTokenKeys,
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(header) = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(token) = header.strip_prefix("Bearer ") else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let claims =
|
||||
match jsonwebtoken::decode::<ArtifactUploadClaims>(token, &keys.decoding, &keys.validation)
|
||||
{
|
||||
Ok(token_data) => token_data.claims,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
if claims.scope != ARTIFACT_UPLOAD_TOKEN_SCOPE {
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
if claims.run_id != run_id.to_string() {
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn authorize_artifact_upload(
|
||||
parts: &Parts,
|
||||
state: &AppState,
|
||||
run_id: &RunId,
|
||||
) -> Result<(), ApiError> {
|
||||
if maybe_authorize_artifact_upload_token(parts, run_id, &state.artifact_upload_tokens)? {
|
||||
return Ok(());
|
||||
}
|
||||
authenticate_service_parts(parts)
|
||||
}
|
||||
|
||||
fn decode_secret_pem(name: &str, raw: &str) -> Result<String, String> {
|
||||
if raw.starts_with("-----") {
|
||||
return Ok(raw.to_string());
|
||||
|
|
@ -2730,9 +2630,29 @@ fn default_env_lookup() -> EnvLookup {
|
|||
}
|
||||
|
||||
fn load_test_server_secrets(path: PathBuf, env: HashMap<String, String>) -> ServerSecrets {
|
||||
let mut env = env;
|
||||
let file_has_session_secret = envfile::read_env_file(&path)
|
||||
.ok()
|
||||
.is_some_and(|entries| entries.contains_key("SESSION_SECRET"));
|
||||
if !env.contains_key("SESSION_SECRET") && !file_has_session_secret {
|
||||
env.insert(
|
||||
"SESSION_SECRET".to_string(),
|
||||
"server-test-session-key-0123456789".to_string(),
|
||||
);
|
||||
}
|
||||
ServerSecrets::load(path, env).expect("test server secrets should load")
|
||||
}
|
||||
|
||||
fn worker_token_keys_from_server_secrets(
|
||||
server_secrets: &ServerSecrets,
|
||||
) -> anyhow::Result<WorkerTokenKeys> {
|
||||
let session_secret = server_secrets
|
||||
.get("SESSION_SECRET")
|
||||
.ok_or_else(|| jwt_auth::session_secret_key_error(&auth::KeyDeriveError::Empty))?;
|
||||
WorkerTokenKeys::from_master_secret(session_secret.as_bytes())
|
||||
.map_err(|err| jwt_auth::session_secret_key_error(&err))
|
||||
}
|
||||
|
||||
pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppState>> {
|
||||
let AppStateConfig {
|
||||
resolved_settings,
|
||||
|
|
@ -2779,12 +2699,13 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
})
|
||||
})
|
||||
};
|
||||
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
|
||||
Ok(Arc::new(AppState {
|
||||
runs: Mutex::new(HashMap::new()),
|
||||
aggregate_billing: Mutex::new(BillingAccumulator::default()),
|
||||
store,
|
||||
artifact_store,
|
||||
artifact_upload_tokens: artifact_upload_token_keys(),
|
||||
worker_tokens,
|
||||
started_at: Instant::now(),
|
||||
max_concurrent_runs,
|
||||
scheduler_notify: Notify::new(),
|
||||
|
|
@ -3286,26 +3207,16 @@ fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId,
|
|||
clippy::result_large_err,
|
||||
reason = "Run ID parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
fn parse_run_id_path(id: &str) -> Result<RunId, Response> {
|
||||
pub(crate) fn parse_run_id_path(id: &str) -> Result<RunId, Response> {
|
||||
id.parse::<RunId>()
|
||||
.map_err(|_| ApiError::bad_request("Invalid run ID.").into_response())
|
||||
}
|
||||
|
||||
/// Public re-export so sibling modules (e.g. `run_files`) can share the same
|
||||
/// 400-on-invalid-ULID parse behavior without duplicating the helper.
|
||||
#[allow(
|
||||
clippy::result_large_err,
|
||||
reason = "This shared run ID parser returns HTTP 400 responses directly."
|
||||
)]
|
||||
pub(crate) fn parse_run_id_path_pub(id: &str) -> Result<RunId, Response> {
|
||||
parse_run_id_path(id)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::result_large_err,
|
||||
reason = "Stage ID parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
||||
pub(crate) fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
||||
StageId::from_str(stage_id)
|
||||
.map_err(|_| ApiError::bad_request("Invalid stage ID.").into_response())
|
||||
}
|
||||
|
|
@ -3314,7 +3225,7 @@ fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
|||
clippy::result_large_err,
|
||||
reason = "Blob ID parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
fn parse_blob_id_path(blob_id: &str) -> Result<RunBlobId, Response> {
|
||||
pub(crate) fn parse_blob_id_path(blob_id: &str) -> Result<RunBlobId, Response> {
|
||||
RunBlobId::from_str(blob_id)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response())
|
||||
}
|
||||
|
|
@ -3884,17 +3795,14 @@ fn worker_command(
|
|||
)
|
||||
})?;
|
||||
let server_target = daemon.bind.to_target();
|
||||
let artifact_upload_token = state
|
||||
.issue_artifact_upload_token(&run_id)
|
||||
.map_err(|_| anyhow::anyhow!("failed to sign artifact upload token"))?;
|
||||
let worker_token = issue_worker_token(state.worker_token_keys(), &run_id)
|
||||
.map_err(|_| anyhow::anyhow!("failed to sign worker token"))?;
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("__run-worker")
|
||||
.arg("--server")
|
||||
.arg(server_target)
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--artifact-upload-token")
|
||||
.arg(artifact_upload_token)
|
||||
.arg("--run-dir")
|
||||
.arg(run_dir)
|
||||
.arg("--run-id")
|
||||
|
|
@ -3906,17 +3814,8 @@ fn worker_command(
|
|||
.stderr(Stdio::piped());
|
||||
|
||||
apply_worker_env(&mut cmd);
|
||||
if state
|
||||
.server_settings()
|
||||
.server
|
||||
.auth
|
||||
.methods
|
||||
.contains(&ServerAuthMethod::DevToken)
|
||||
{
|
||||
if let Some(token) = state.server_secret("FABRO_DEV_TOKEN") {
|
||||
cmd.env("FABRO_DEV_TOKEN", token);
|
||||
}
|
||||
}
|
||||
cmd.env_remove("FABRO_WORKER_TOKEN");
|
||||
cmd.env("FABRO_WORKER_TOKEN", worker_token);
|
||||
|
||||
#[cfg(unix)]
|
||||
fabro_proc::pre_exec_setpgid(cmd.as_std_mut());
|
||||
|
|
@ -4484,7 +4383,7 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
|
|||
}
|
||||
|
||||
if state.registry_factory_override.is_some() {
|
||||
execute_run_in_process(state, run_id).await;
|
||||
Box::pin(execute_run_in_process(state, run_id)).await;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5233,14 +5132,9 @@ async fn submit_answer(
|
|||
}
|
||||
|
||||
async fn get_run_state(
|
||||
_auth: AuthenticatedService,
|
||||
AuthorizeRunScoped(id): AuthorizeRunScoped,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match state.store.open_run_reader(&id).await {
|
||||
Ok(run_store) => match run_store.state().await {
|
||||
Ok(run_state) => Json(run_state).into_response(),
|
||||
|
|
@ -5253,15 +5147,10 @@ async fn get_run_state(
|
|||
}
|
||||
|
||||
async fn append_run_event(
|
||||
_auth: AuthenticatedService,
|
||||
AuthorizeRunScoped(id): AuthorizeRunScoped,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(value): Json<serde_json::Value>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -5303,15 +5192,10 @@ async fn append_run_event(
|
|||
}
|
||||
|
||||
async fn list_run_events(
|
||||
_auth: AuthenticatedService,
|
||||
AuthorizeRunScoped(id): AuthorizeRunScoped,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<EventListParams>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let since_seq = params.since_seq();
|
||||
let limit = params.limit();
|
||||
match state.store.open_run_reader(&id).await {
|
||||
|
|
@ -5509,15 +5393,10 @@ async fn get_checkpoint(
|
|||
}
|
||||
|
||||
async fn write_run_blob(
|
||||
_auth: AuthenticatedService,
|
||||
AuthorizeRunScoped(id): AuthorizeRunScoped,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -5536,18 +5415,9 @@ async fn write_run_blob(
|
|||
}
|
||||
|
||||
async fn read_run_blob(
|
||||
_auth: AuthenticatedService,
|
||||
AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, blob_id)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let blob_id = match parse_blob_id_path(&blob_id) {
|
||||
Ok(blob_id) => blob_id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match state.store.open_run_reader(&id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_id).await {
|
||||
Ok(Some(bytes)) => octet_stream_response(bytes),
|
||||
|
|
@ -5996,23 +5866,11 @@ async fn upload_stage_artifact_multipart(
|
|||
|
||||
async fn put_stage_artifact(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, stage_id)): Path<(String, String)>,
|
||||
AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact,
|
||||
Query(params): Query<ArtifactFilenameParams>,
|
||||
request: axum_extract::Request,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let stage_id = match parse_stage_id_path(&stage_id) {
|
||||
Ok(stage_id) => stage_id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let (parts, body) = request.into_parts();
|
||||
|
||||
if let Err(err) = authorize_artifact_upload(&parts, state.as_ref(), &id) {
|
||||
return err.into_response();
|
||||
}
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -7456,13 +7314,15 @@ mod tests {
|
|||
use std::process::Stdio;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, header};
|
||||
use chrono::Utc;
|
||||
use axum::http::{Method, Request, header};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_config::bind::Bind;
|
||||
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
||||
use fabro_model::Provider;
|
||||
use fabro_types::settings::ServerAuthMethod;
|
||||
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
|
||||
use fabro_types::{
|
||||
InterviewQuestionRecord, InterviewQuestionType, RunAuthMethod, RunBlobId, RunId, fixtures,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio_stream::StreamExt as _;
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -7480,6 +7340,8 @@ mod tests {
|
|||
const TEST_WEBHOOK_SECRET: &str = "webhook-secret";
|
||||
const TEST_DEV_TOKEN: &str =
|
||||
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
const TEST_SESSION_SECRET: &str = "server-test-session-key-0123456789";
|
||||
const TEST_JWT_ISSUER: &str = "https://fabro.example";
|
||||
const WRONG_DEV_TOKEN: &str =
|
||||
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
|
||||
|
||||
|
|
@ -7598,6 +7460,88 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
fn jwt_auth_mode() -> AuthMode {
|
||||
AuthMode::Enabled(ConfiguredAuth {
|
||||
methods: vec![ServerAuthMethod::Github],
|
||||
dev_token: None,
|
||||
jwt_key: Some(
|
||||
auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes())
|
||||
.expect("test JWT key should derive"),
|
||||
),
|
||||
jwt_issuer: Some(TEST_JWT_ISSUER.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn jwt_auth_state() -> Arc<AppState> {
|
||||
create_test_app_state_with_session_key(
|
||||
default_test_server_settings(),
|
||||
RunLayer::default(),
|
||||
Some(TEST_SESSION_SECRET),
|
||||
)
|
||||
}
|
||||
|
||||
fn jwt_auth_app() -> (Arc<AppState>, Router) {
|
||||
let state = jwt_auth_state();
|
||||
let app = build_router(Arc::clone(&state), jwt_auth_mode());
|
||||
(state, app)
|
||||
}
|
||||
|
||||
fn test_user_subject() -> auth::JwtSubject {
|
||||
auth::JwtSubject {
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: "https://example.com/octocat.png".to_string(),
|
||||
user_url: "https://github.com/octocat".to_string(),
|
||||
auth_method: RunAuthMethod::Github,
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_test_user_jwt() -> String {
|
||||
let key = auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes())
|
||||
.expect("test JWT key should derive");
|
||||
auth::issue(
|
||||
&key,
|
||||
TEST_JWT_ISSUER,
|
||||
&test_user_subject(),
|
||||
ChronoDuration::minutes(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn issue_test_worker_token(run_id: &RunId) -> String {
|
||||
let keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes())
|
||||
.expect("worker keys should derive");
|
||||
issue_worker_token(&keys, run_id).expect("worker token should issue")
|
||||
}
|
||||
|
||||
async fn create_run_with_bearer(app: &Router, bearer: &str) -> RunId {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {bearer}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(manifest_body(MINIMAL_DOT))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json!(response, StatusCode::CREATED).await;
|
||||
body["id"].as_str().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
fn bearer_request(method: Method, path: &str, bearer: &str, body: Body) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {bearer}"))
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn canonical_origin_settings(url: &str) -> ServerSettings {
|
||||
server_settings_from_toml(&format!(
|
||||
r#"
|
||||
|
|
@ -8174,36 +8118,121 @@ provider = "invalid-provider"
|
|||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_injects_dev_token_only_when_enabled() {
|
||||
fn worker_command_always_sets_worker_token_env() {
|
||||
let github_only = tempfile::tempdir().unwrap();
|
||||
let github_state =
|
||||
worker_command_test_state(github_only.path(), &["github"], Some(TEST_DEV_TOKEN));
|
||||
let github_run_id = RunId::new();
|
||||
let github_cmd = worker_command(
|
||||
github_state.as_ref(),
|
||||
RunId::new(),
|
||||
github_run_id,
|
||||
RunExecutionMode::Start,
|
||||
github_only.path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
command_env_value(&github_cmd, "FABRO_WORKER_TOKEN"),
|
||||
EnvOverride::Set(_)
|
||||
));
|
||||
assert_eq!(
|
||||
command_env_value(&github_cmd, "FABRO_DEV_TOKEN"),
|
||||
EnvOverride::Unchanged
|
||||
);
|
||||
let github_args = github_cmd
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!github_args
|
||||
.iter()
|
||||
.any(|arg| arg == "--artifact-upload-token")
|
||||
);
|
||||
assert!(!github_args.iter().any(|arg| arg == "--worker-token"));
|
||||
let EnvOverride::Set(github_token) = command_env_value(&github_cmd, "FABRO_WORKER_TOKEN")
|
||||
else {
|
||||
panic!("worker token should be set");
|
||||
};
|
||||
let github_keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes())
|
||||
.expect("worker keys should derive");
|
||||
let github_claims = jsonwebtoken::decode::<crate::worker_token::WorkerTokenClaims>(
|
||||
&github_token,
|
||||
github_keys.decoding_key(),
|
||||
github_keys.validation(),
|
||||
)
|
||||
.expect("github worker token should decode")
|
||||
.claims;
|
||||
assert_eq!(github_claims.run_id, github_run_id.to_string());
|
||||
|
||||
let dev_token = tempfile::tempdir().unwrap();
|
||||
let dev_token_state =
|
||||
worker_command_test_state(dev_token.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
|
||||
let dev_token_run_id = RunId::new();
|
||||
let dev_token_cmd = worker_command(
|
||||
dev_token_state.as_ref(),
|
||||
RunId::new(),
|
||||
dev_token_run_id,
|
||||
RunExecutionMode::Start,
|
||||
dev_token.path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN"),
|
||||
EnvOverride::Set(_)
|
||||
));
|
||||
assert_eq!(
|
||||
command_env_value(&dev_token_cmd, "FABRO_DEV_TOKEN"),
|
||||
EnvOverride::Set(TEST_DEV_TOKEN.to_string())
|
||||
EnvOverride::Unchanged
|
||||
);
|
||||
let EnvOverride::Set(dev_worker_token) =
|
||||
command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN")
|
||||
else {
|
||||
panic!("worker token should be set");
|
||||
};
|
||||
let dev_claims = jsonwebtoken::decode::<crate::worker_token::WorkerTokenClaims>(
|
||||
&dev_worker_token,
|
||||
github_keys.decoding_key(),
|
||||
github_keys.validation(),
|
||||
)
|
||||
.expect("dev-token worker token should decode")
|
||||
.claims;
|
||||
assert_eq!(dev_claims.run_id, dev_token_run_id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_app_state_requires_session_secret_for_worker_tokens() {
|
||||
let server_settings = server_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["dev-token"]
|
||||
"#,
|
||||
);
|
||||
let (store, artifact_store) = test_store_bundle();
|
||||
let vault_path = test_secret_store_path();
|
||||
let server_env_path = vault_path.with_file_name("server.env");
|
||||
let Err(err) = build_app_state(AppStateConfig {
|
||||
resolved_settings: resolved_runtime_settings_for_tests(
|
||||
server_settings,
|
||||
RunLayer::default(),
|
||||
),
|
||||
registry_factory_override: None,
|
||||
max_concurrent_runs: 5,
|
||||
store,
|
||||
artifact_store,
|
||||
vault_path,
|
||||
server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(),
|
||||
env_lookup: default_env_lookup(),
|
||||
http_client: Some(
|
||||
fabro_http::test_http_client().expect("test HTTP client should build"),
|
||||
),
|
||||
}) else {
|
||||
panic!("build_app_state should require SESSION_SECRET")
|
||||
};
|
||||
|
||||
assert!(err.to_string().contains(
|
||||
"Fabro server refuses to start: auth is configured but SESSION_SECRET is not set."
|
||||
));
|
||||
}
|
||||
|
||||
fn worker_command_test_state(
|
||||
|
|
@ -9520,6 +9549,284 @@ slug = "fabro"
|
|||
assert_status!(response, StatusCode::BAD_REQUEST).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
||||
let (state, app) = jwt_auth_app();
|
||||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let other_worker_token = issue_test_worker_token(&other_run_id);
|
||||
let blob_id = state
|
||||
.store
|
||||
.open_run(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.write_blob(b"preloaded blob")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/state"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let append_body = serde_json::to_vec(&serde_json::json!({
|
||||
"id": "evt-run-notice",
|
||||
"ts": "2026-04-23T12:00:00Z",
|
||||
"event": "run.notice",
|
||||
"run_id": run_id.to_string(),
|
||||
"properties": {
|
||||
"level": "info",
|
||||
"code": "worker",
|
||||
"message": "hello"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api(&format!("/runs/{run_id}/events")))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {worker_token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(append_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/events"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
&format!("/runs/{run_id}/blobs"),
|
||||
&worker_token,
|
||||
Body::from("worker blob"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/state"),
|
||||
&user_jwt,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::OK).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/state"),
|
||||
&other_worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::FORBIDDEN).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_token_controls_stage_artifact_route() {
|
||||
let (_state, app) = jwt_auth_app();
|
||||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let mismatched_worker_token = issue_test_worker_token(&other_run_id);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt"
|
||||
)))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {worker_token}"))
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from("artifact"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::NO_CONTENT).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt"
|
||||
)))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {user_jwt}"))
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from("artifact"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::NO_CONTENT).await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt"
|
||||
)))
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {mismatched_worker_token}"),
|
||||
)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from("artifact"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::FORBIDDEN).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt"
|
||||
)))
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from("artifact"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_status!(response, StatusCode::UNAUTHORIZED).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_token_is_rejected_on_user_only_routes() {
|
||||
let (_state, app) = jwt_auth_app();
|
||||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let blob_id = RunBlobId::new(b"blob");
|
||||
let user_only_routes = vec![
|
||||
(Method::GET, "/runs".to_string()),
|
||||
(Method::POST, "/runs".to_string()),
|
||||
(Method::GET, "/runs/resolve".to_string()),
|
||||
(Method::POST, "/preflight".to_string()),
|
||||
(Method::POST, "/graph/render".to_string()),
|
||||
(Method::GET, "/attach".to_string()),
|
||||
(Method::GET, "/boards/runs".to_string()),
|
||||
(Method::GET, format!("/runs/{run_id}")),
|
||||
(Method::DELETE, format!("/runs/{run_id}")),
|
||||
(Method::GET, format!("/runs/{run_id}/questions")),
|
||||
(Method::POST, format!("/runs/{run_id}/questions/q-1/answer")),
|
||||
(Method::GET, format!("/runs/{run_id}/attach")),
|
||||
(Method::GET, format!("/runs/{run_id}/checkpoint")),
|
||||
(Method::POST, format!("/runs/{run_id}/cancel")),
|
||||
(Method::POST, format!("/runs/{run_id}/start")),
|
||||
(Method::POST, format!("/runs/{run_id}/pause")),
|
||||
(Method::POST, format!("/runs/{run_id}/unpause")),
|
||||
(Method::POST, format!("/runs/{run_id}/archive")),
|
||||
(Method::POST, format!("/runs/{run_id}/unarchive")),
|
||||
(Method::GET, format!("/runs/{run_id}/graph")),
|
||||
(Method::GET, format!("/runs/{run_id}/stages")),
|
||||
(Method::GET, format!("/runs/{run_id}/artifacts")),
|
||||
(Method::GET, format!("/runs/{run_id}/files")),
|
||||
(
|
||||
Method::GET,
|
||||
format!("/runs/{run_id}/stages/code@2/artifacts"),
|
||||
),
|
||||
(
|
||||
Method::GET,
|
||||
format!("/runs/{run_id}/stages/code@2/artifacts/download"),
|
||||
),
|
||||
(Method::GET, format!("/runs/{run_id}/billing")),
|
||||
(Method::GET, format!("/runs/{run_id}/settings")),
|
||||
(Method::POST, format!("/runs/{run_id}/preview")),
|
||||
(Method::POST, format!("/runs/{run_id}/ssh")),
|
||||
(Method::GET, format!("/runs/{run_id}/sandbox/files")),
|
||||
(Method::GET, format!("/runs/{run_id}/sandbox/file")),
|
||||
(Method::PUT, format!("/runs/{run_id}/sandbox/file")),
|
||||
];
|
||||
|
||||
for (method, path) in user_only_routes {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
method.clone(),
|
||||
&path,
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
),
|
||||
"{method} {path} unexpectedly accepted worker token with status {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_artifacts_multipart_round_trip() {
|
||||
let state = create_app_state();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ mod tests {
|
|||
"/tmp/fabro-storage".to_string(),
|
||||
),
|
||||
("SESSION_SECRET".to_string(), "leak".to_string()),
|
||||
("FABRO_JWT_PRIVATE_KEY".to_string(), "leak".to_string()),
|
||||
("FABRO_JWT_PUBLIC_KEY".to_string(), "leak".to_string()),
|
||||
("GITHUB_APP_PRIVATE_KEY".to_string(), "leak".to_string()),
|
||||
("GITHUB_APP_CLIENT_SECRET".to_string(), "leak".to_string()),
|
||||
("GITHUB_APP_WEBHOOK_SECRET".to_string(), "leak".to_string()),
|
||||
("FABRO_DEV_TOKEN".to_string(), "garbage".to_string()),
|
||||
("MY_API_KEY".to_string(), "blocked".to_string()),
|
||||
]);
|
||||
|
|
@ -95,6 +100,11 @@ mod tests {
|
|||
Some("fabro_dev_abababababababababababababababababababababababababababababababab")
|
||||
);
|
||||
assert!(!actual.contains_key("SESSION_SECRET"));
|
||||
assert!(!actual.contains_key("FABRO_JWT_PRIVATE_KEY"));
|
||||
assert!(!actual.contains_key("FABRO_JWT_PUBLIC_KEY"));
|
||||
assert!(!actual.contains_key("GITHUB_APP_PRIVATE_KEY"));
|
||||
assert!(!actual.contains_key("GITHUB_APP_CLIENT_SECRET"));
|
||||
assert!(!actual.contains_key("GITHUB_APP_WEBHOOK_SECRET"));
|
||||
assert!(!actual.contains_key("MY_API_KEY"));
|
||||
}
|
||||
|
||||
|
|
|
|||
599
lib/crates/fabro-server/src/worker_token.rs
Normal file
599
lib/crates/fabro-server/src/worker_token.rs
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::{FromRequestParts, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::request::Parts;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_types::{RunBlobId, RunId, StageId};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ApiError;
|
||||
use crate::auth::{self, KeyDeriveError};
|
||||
use crate::jwt_auth::{authenticate_service_parts, bearer_token};
|
||||
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
|
||||
|
||||
pub(crate) const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
|
||||
pub(crate) const WORKER_TOKEN_SCOPE: &str = "run:worker";
|
||||
pub(crate) const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WorkerTokenKeys {
|
||||
encoding: Arc<EncodingKey>,
|
||||
decoding: Arc<DecodingKey>,
|
||||
validation: Arc<Validation>,
|
||||
}
|
||||
|
||||
impl WorkerTokenKeys {
|
||||
pub(crate) fn from_master_secret(secret: &[u8]) -> Result<Self, KeyDeriveError> {
|
||||
let key = auth::derive_worker_jwt_key(secret)?;
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_nbf = false;
|
||||
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
|
||||
validation.set_issuer(&[WORKER_TOKEN_ISSUER]);
|
||||
|
||||
Ok(Self {
|
||||
encoding: Arc::new(EncodingKey::from_secret(&key)),
|
||||
decoding: Arc::new(DecodingKey::from_secret(&key)),
|
||||
validation: Arc::new(validation),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn decoding_key(&self) -> &DecodingKey {
|
||||
&self.decoding
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn validation(&self) -> &Validation {
|
||||
&self.validation
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct WorkerTokenClaims {
|
||||
pub(crate) iss: String,
|
||||
pub(crate) iat: u64,
|
||||
pub(crate) exp: u64,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) scope: String,
|
||||
pub(crate) jti: String,
|
||||
}
|
||||
|
||||
pub(crate) fn issue_worker_token(
|
||||
keys: &WorkerTokenKeys,
|
||||
run_id: &RunId,
|
||||
) -> Result<String, ApiError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_secs());
|
||||
let claims = WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: now,
|
||||
exp: now + WORKER_TOKEN_TTL_SECS,
|
||||
run_id: run_id.to_string(),
|
||||
scope: WORKER_TOKEN_SCOPE.to_string(),
|
||||
jti: Uuid::new_v4().simple().to_string(),
|
||||
};
|
||||
jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding).map_err(|err| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to sign worker token: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn authorize_worker_token(
|
||||
parts: &Parts,
|
||||
run_id: &RunId,
|
||||
keys: &WorkerTokenKeys,
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(Ok(token)) = bearer_token(parts) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let claims =
|
||||
match jsonwebtoken::decode::<WorkerTokenClaims>(token, &keys.decoding, &keys.validation) {
|
||||
Ok(token_data) => token_data.claims,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
if claims.scope != WORKER_TOKEN_SCOPE {
|
||||
warn!(
|
||||
target: "worker_auth",
|
||||
run_id = %run_id,
|
||||
jti = %claims.jti,
|
||||
reason = "wrong_scope",
|
||||
"worker token rejected"
|
||||
);
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
if claims.run_id != run_id.to_string() {
|
||||
warn!(
|
||||
target: "worker_auth",
|
||||
run_id = %run_id,
|
||||
token_run_id = %claims.run_id,
|
||||
jti = %claims.jti,
|
||||
reason = "run_id_mismatch",
|
||||
"worker token rejected"
|
||||
);
|
||||
return Err(ApiError::forbidden());
|
||||
}
|
||||
|
||||
info!(
|
||||
target: "worker_auth",
|
||||
run_id = %run_id,
|
||||
jti = %claims.jti,
|
||||
"worker token accepted"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn authorize_run_scoped(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<(), ApiError> {
|
||||
if authorize_worker_token(parts, run_id, state.worker_token_keys())? {
|
||||
return Ok(());
|
||||
}
|
||||
authenticate_service_parts(parts)
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizeRunScoped(pub(crate) RunId);
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthorizeRunScoped {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path(id): Path<String> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
authorize_run_scoped(parts, state.as_ref(), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizeRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthorizeRunBlob {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
let blob_id = parse_blob_id_path(&blob_id)?;
|
||||
authorize_run_scoped(parts, state.as_ref(), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id, blob_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizeStageArtifact(pub(crate) RunId, pub(crate) StageId);
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthorizeStageArtifact {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
let stage_id = parse_stage_id_path(&stage_id)?;
|
||||
authorize_run_scoped(parts, state.as_ref(), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id, stage_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use axum::http::header;
|
||||
use axum::http::request::Parts;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use jsonwebtoken::{Algorithm, Header, decode};
|
||||
use serde_json::json;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber, subscriber};
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerTokenClaims, WorkerTokenKeys,
|
||||
authorize_worker_token, issue_worker_token,
|
||||
};
|
||||
use crate::auth;
|
||||
|
||||
const TEST_SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
|
||||
const OTHER_SECRET: &[u8] = b"fedcba9876543210fedcba9876543210";
|
||||
|
||||
fn keys(secret: &[u8]) -> WorkerTokenKeys {
|
||||
WorkerTokenKeys::from_master_secret(secret).expect("worker keys should derive")
|
||||
}
|
||||
|
||||
fn run_id() -> fabro_types::RunId {
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap()
|
||||
}
|
||||
|
||||
fn other_run_id() -> fabro_types::RunId {
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap()
|
||||
}
|
||||
|
||||
fn request_parts(authorization: Option<&str>) -> Parts {
|
||||
let mut builder = axum::http::Request::builder();
|
||||
if let Some(authorization) = authorization {
|
||||
builder = builder.header(header::AUTHORIZATION, authorization);
|
||||
}
|
||||
let (parts, ()) = builder.body(()).unwrap().into_parts();
|
||||
parts
|
||||
}
|
||||
|
||||
fn bearer_parts(token: &str) -> Parts {
|
||||
request_parts(Some(&format!("Bearer {token}")))
|
||||
}
|
||||
|
||||
fn wrong_scope_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String {
|
||||
let claims = WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: 1,
|
||||
exp: u64::MAX / 2,
|
||||
run_id: run_id.to_string(),
|
||||
scope: "wrong:scope".to_string(),
|
||||
jti: Uuid::new_v4().simple().to_string(),
|
||||
};
|
||||
jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding)
|
||||
.expect("test token should encode")
|
||||
}
|
||||
|
||||
fn expired_worker_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String {
|
||||
let claims = WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
run_id: run_id.to_string(),
|
||||
scope: WORKER_TOKEN_SCOPE.to_string(),
|
||||
jti: Uuid::new_v4().simple().to_string(),
|
||||
};
|
||||
jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding)
|
||||
.expect("expired test token should encode")
|
||||
}
|
||||
|
||||
fn alg_none_token(run_id: &fabro_types::RunId) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&json!({
|
||||
"alg": "none",
|
||||
"typ": "JWT",
|
||||
}))
|
||||
.expect("jwt header should serialize"),
|
||||
);
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&json!({
|
||||
"iss": WORKER_TOKEN_ISSUER,
|
||||
"iat": 1_u64,
|
||||
"exp": u64::MAX / 2,
|
||||
"run_id": run_id.to_string(),
|
||||
"scope": WORKER_TOKEN_SCOPE,
|
||||
"jti": Uuid::new_v4().simple().to_string(),
|
||||
}))
|
||||
.expect("jwt payload should serialize"),
|
||||
);
|
||||
format!("{header}.{payload}.")
|
||||
}
|
||||
|
||||
fn issue_user_jwt() -> String {
|
||||
let subject = auth::JwtSubject {
|
||||
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
avatar_url: "https://example.com/octocat.png".to_string(),
|
||||
user_url: "https://github.com/octocat".to_string(),
|
||||
auth_method: fabro_types::RunAuthMethod::Github,
|
||||
};
|
||||
let key = auth::derive_jwt_key(TEST_SECRET).expect("user jwt key should derive");
|
||||
auth::issue(
|
||||
&key,
|
||||
"https://fabro.example",
|
||||
&subject,
|
||||
ChronoDuration::minutes(10),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LogCapture {
|
||||
target: String,
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LogCaptureVisitor {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Visit for LogCaptureVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.fields
|
||||
.push((field.name().to_string(), format!("{value:?}")));
|
||||
}
|
||||
}
|
||||
|
||||
struct LogCaptureLayer {
|
||||
events: Arc<StdMutex<Vec<LogCapture>>>,
|
||||
}
|
||||
|
||||
impl<S: Subscriber> Layer<S> for LogCaptureLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
if event.metadata().target() != "worker_auth" {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut visitor = LogCaptureVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
self.events.lock().unwrap().push(LogCapture {
|
||||
target: event.metadata().target().to_string(),
|
||||
fields: visitor.fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Arc<StdMutex<Vec<LogCapture>>>) {
|
||||
let events = Arc::new(StdMutex::new(Vec::<LogCapture>::new()));
|
||||
let layer = LogCaptureLayer {
|
||||
events: Arc::clone(&events),
|
||||
};
|
||||
let subscriber = Registry::default().with(layer);
|
||||
let result = subscriber::with_default(subscriber, f);
|
||||
(result, events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_worker_token_round_trips_claims() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
|
||||
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
|
||||
let decoded = decode::<WorkerTokenClaims>(&token, &keys.decoding, &keys.validation)
|
||||
.expect("worker token should decode");
|
||||
|
||||
assert_eq!(decoded.claims, WorkerTokenClaims {
|
||||
iss: WORKER_TOKEN_ISSUER.to_string(),
|
||||
iat: decoded.claims.iat,
|
||||
exp: decoded.claims.exp,
|
||||
run_id: run_id.to_string(),
|
||||
scope: WORKER_TOKEN_SCOPE.to_string(),
|
||||
jti: decoded.claims.jti.clone(),
|
||||
});
|
||||
assert_eq!(decoded.header.alg, Algorithm::HS256);
|
||||
assert_eq!(decoded.claims.jti.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_token_survives_key_rederivation() {
|
||||
let run_id = run_id();
|
||||
let first = keys(TEST_SECRET);
|
||||
let second = keys(TEST_SECRET);
|
||||
|
||||
let token = issue_worker_token(&first, &run_id).expect("worker token should issue");
|
||||
let decoded = decode::<WorkerTokenClaims>(&token, &second.decoding, &second.validation)
|
||||
.expect("worker token should decode after re-derivation");
|
||||
|
||||
assert_eq!(decoded.claims.run_id, run_id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_token_fails_under_rotated_secret() {
|
||||
let run_id = run_id();
|
||||
let first = keys(TEST_SECRET);
|
||||
let second = keys(OTHER_SECRET);
|
||||
|
||||
let token = issue_worker_token(&first, &run_id).expect("worker token should issue");
|
||||
let err = decode::<WorkerTokenClaims>(&token, &second.decoding, &second.validation)
|
||||
.expect_err("rotated secret should reject the token");
|
||||
assert!(matches!(
|
||||
err.kind(),
|
||||
jsonwebtoken::errors::ErrorKind::InvalidSignature
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_key_is_distinct_from_user_jwt_key() {
|
||||
let user_key = auth::derive_jwt_key(TEST_SECRET).expect("user key should derive");
|
||||
let worker_key =
|
||||
auth::derive_worker_jwt_key(TEST_SECRET).expect("worker key should derive");
|
||||
|
||||
assert_ne!(user_key.as_bytes(), worker_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_accepts_matching_run_id() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
assert!(authorize_worker_token(&parts, &run_id, &keys).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_rejects_cross_run_reuse() {
|
||||
let run_id = run_id();
|
||||
let other_run_id = other_run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue");
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let err = authorize_worker_token(&parts, &run_id, &keys)
|
||||
.expect_err("mismatched run should reject");
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_rejects_wrong_scope() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = wrong_scope_token(&keys, &run_id);
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let err =
|
||||
authorize_worker_token(&parts, &run_id, &keys).expect_err("wrong scope should reject");
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_falls_through_without_header() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let parts = request_parts(None);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
assert!(!result.unwrap());
|
||||
assert!(captured.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_falls_through_for_user_jwt_without_worker_logs() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = issue_user_jwt();
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
assert!(!result.unwrap());
|
||||
assert!(captured.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_falls_through_for_expired_token_without_worker_logs() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = expired_worker_token(&keys, &run_id);
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
assert!(!result.unwrap());
|
||||
assert!(captured.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_falls_through_for_bad_signature_without_worker_logs() {
|
||||
let run_id = run_id();
|
||||
let signer = keys(OTHER_SECRET);
|
||||
let verifier = keys(TEST_SECRET);
|
||||
let token = issue_worker_token(&signer, &run_id).expect("worker token should issue");
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) =
|
||||
capture_logs(|| authorize_worker_token(&parts, &run_id, &verifier));
|
||||
|
||||
assert!(!result.unwrap());
|
||||
assert!(captured.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_falls_through_for_alg_none_without_worker_logs() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = alg_none_token(&run_id);
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
assert!(!result.unwrap());
|
||||
assert!(captured.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_logs_acceptance() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
assert!(result.unwrap());
|
||||
let events = captured.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].target, "worker_auth");
|
||||
assert!(events[0]
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(field, value)| field == "message" && value.contains("worker token accepted")));
|
||||
assert!(
|
||||
events[0]
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(field, value)| field == "run_id" && value.contains(&run_id.to_string()))
|
||||
);
|
||||
assert!(
|
||||
events[0]
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(field, value)| field == "jti" && !value.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_logs_run_id_mismatch() {
|
||||
let run_id = run_id();
|
||||
let other_run_id = other_run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue");
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
let err = result.expect_err("mismatched run should reject");
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
let events = captured.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].target, "worker_auth");
|
||||
assert!(
|
||||
events[0]
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(field, value)| field == "reason" && value.contains("run_id_mismatch"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_worker_token_logs_wrong_scope() {
|
||||
let run_id = run_id();
|
||||
let keys = keys(TEST_SECRET);
|
||||
let token = wrong_scope_token(&keys, &run_id);
|
||||
let parts = bearer_parts(&token);
|
||||
|
||||
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
|
||||
|
||||
let err = result.expect_err("wrong scope should reject");
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
let events = captured.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].target, "worker_auth");
|
||||
assert!(
|
||||
events[0]
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(field, value)| field == "reason" && value.contains("wrong_scope"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,15 @@ impl ActorRef {
|
|||
display,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn system_worker() -> Self {
|
||||
Self {
|
||||
kind: ActorKind::System,
|
||||
id: Some("worker".to_string()),
|
||||
display: Some("system:worker".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -2649,11 +2649,16 @@ pub enum RunEventSink {
|
|||
Store(RunStoreHandle),
|
||||
JsonLines(Arc<AsyncMutex<Pin<Box<dyn AsyncWrite + Send>>>>),
|
||||
Callback(Arc<RunEventSinkCallback>),
|
||||
Map {
|
||||
transform: Arc<RunEventTransform>,
|
||||
inner: Box<Self>,
|
||||
},
|
||||
Composite(Vec<Self>),
|
||||
}
|
||||
|
||||
type RunEventSinkFuture = Pin<Box<dyn Future<Output = Result<()>> + Send + 'static>>;
|
||||
type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static;
|
||||
type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static;
|
||||
|
||||
impl RunEventSink {
|
||||
#[must_use]
|
||||
|
|
@ -2695,23 +2700,39 @@ impl RunEventSink {
|
|||
Self::Composite(flattened)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn map<F>(transform: F, inner: Self) -> Self
|
||||
where
|
||||
F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static,
|
||||
{
|
||||
Self::Map {
|
||||
transform: Arc::new(transform),
|
||||
inner: Box::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> {
|
||||
let mut pending = vec![self];
|
||||
while let Some(sink) = pending.pop() {
|
||||
let mut pending = vec![(self, event.clone())];
|
||||
while let Some((sink, event)) = pending.pop() {
|
||||
match sink {
|
||||
Self::Store(run_store) => {
|
||||
run_store.append_run_event(event).await?;
|
||||
run_store.append_run_event(&event).await?;
|
||||
}
|
||||
Self::JsonLines(writer) => {
|
||||
let line = redacted_event_json(event)?;
|
||||
let line = redacted_event_json(&event)?;
|
||||
let mut writer = writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Self::Callback(callback) => callback(event.clone()).await?,
|
||||
Self::Callback(callback) => callback(event).await?,
|
||||
Self::Map { transform, inner } => {
|
||||
pending.push((inner.as_ref(), transform(event)));
|
||||
}
|
||||
Self::Composite(sinks) => {
|
||||
pending.extend(sinks.iter().rev());
|
||||
for sink in sinks.iter().rev() {
|
||||
pending.push((sink, event.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3174,6 +3195,46 @@ mod tests {
|
|||
assert_eq!(payload.as_value()["properties"]["action"], "pause");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_event_sink_map_applies_transform_before_fanout() {
|
||||
let first = Arc::new(AsyncMutex::new(Vec::new()));
|
||||
let second = Arc::new(AsyncMutex::new(Vec::new()));
|
||||
let first_events = Arc::clone(&first);
|
||||
let second_events = Arc::clone(&second);
|
||||
let sink = RunEventSink::map(
|
||||
|mut event| {
|
||||
event.actor = Some(ActorRef::user("alice".to_string()));
|
||||
event
|
||||
},
|
||||
RunEventSink::fanout(vec![
|
||||
RunEventSink::callback(move |event| {
|
||||
let first_events = Arc::clone(&first_events);
|
||||
async move {
|
||||
first_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
RunEventSink::callback(move |event| {
|
||||
let second_events = Arc::clone(&second_events);
|
||||
async move {
|
||||
second_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
]),
|
||||
);
|
||||
let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None });
|
||||
|
||||
sink.write_run_event(&event).await.unwrap();
|
||||
|
||||
let first = first.lock().await;
|
||||
let second = second.lock().await;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert_eq!(first[0].actor, Some(ActorRef::user("alice".to_string())));
|
||||
assert_eq!(second[0].actor, Some(ActorRef::user("alice".to_string())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_event_logger_registers_emitter_events_to_json_lines() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -34,22 +33,17 @@ const ARTIFACT_UPLOAD_RETRY_DELAYS: [Duration; 3] = [
|
|||
|
||||
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
|
||||
pub(crate) struct ArtifactLifecycle {
|
||||
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
|
||||
pub run_store: RunStoreHandle,
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub run_id: RunId,
|
||||
pub artifact_globs: Vec<String>,
|
||||
pub artifact_sink: Option<ArtifactSink>,
|
||||
pub captured_artifact_count: Arc<AtomicUsize>,
|
||||
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
|
||||
pub run_store: RunStoreHandle,
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub run_id: RunId,
|
||||
pub artifact_globs: Vec<String>,
|
||||
pub artifact_sink: Option<ArtifactSink>,
|
||||
/// Per-attempt state: epoch seconds when the attempt started.
|
||||
attempt_start_epoch: std::sync::Mutex<Option<f64>>,
|
||||
attempt_start_epoch: std::sync::Mutex<Option<f64>>,
|
||||
}
|
||||
|
||||
impl ArtifactLifecycle {
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Artifact capture setup needs the run-scoped collaborators up front."
|
||||
)]
|
||||
pub(crate) fn new(
|
||||
sandbox: Arc<dyn fabro_sandbox::Sandbox>,
|
||||
run_store: RunStoreHandle,
|
||||
|
|
@ -57,7 +51,6 @@ impl ArtifactLifecycle {
|
|||
run_id: RunId,
|
||||
artifact_globs: Vec<String>,
|
||||
artifact_sink: Option<ArtifactSink>,
|
||||
captured_artifact_count: Arc<AtomicUsize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sandbox,
|
||||
|
|
@ -66,7 +59,6 @@ impl ArtifactLifecycle {
|
|||
run_id,
|
||||
artifact_globs,
|
||||
artifact_sink,
|
||||
captured_artifact_count,
|
||||
attempt_start_epoch: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +67,6 @@ impl ArtifactLifecycle {
|
|||
#[async_trait]
|
||||
impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
|
||||
async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> {
|
||||
self.captured_artifact_count.store(0, Ordering::Relaxed);
|
||||
*self.attempt_start_epoch.lock().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -139,7 +130,6 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
|
|||
}
|
||||
let scope = stage_scope_for(state, node_id);
|
||||
for asset in &summary.captured_assets {
|
||||
self.captured_artifact_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.emitter.emit_scoped(
|
||||
&Event::ArtifactCaptured {
|
||||
node_id: node_id.to_string(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -11,12 +10,11 @@ use fabro_core::lifecycle::{
|
|||
};
|
||||
use fabro_core::outcome::NodeResult;
|
||||
use fabro_core::state::ExecutionState;
|
||||
use fabro_types::{BilledTokenCounts, FailureReason, RunId, SuccessReason};
|
||||
use fabro_types::RunId;
|
||||
|
||||
use super::circuit_breaker::CircuitBreakerLifecycle;
|
||||
use super::git::GitCheckpointResult;
|
||||
use crate::context::WorkflowContext;
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::graph::{WorkflowGraph, WorkflowNode};
|
||||
use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus};
|
||||
|
|
@ -31,25 +29,23 @@ type FailureSignatureSnapshot = (
|
|||
|
||||
/// Sub-lifecycle responsible for emitting workflow run events.
|
||||
pub(crate) struct EventLifecycle {
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub graph_name: String,
|
||||
pub run_id: RunId,
|
||||
pub run_start: Mutex<Instant>,
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub graph_name: String,
|
||||
pub run_id: RunId,
|
||||
pub run_start: Mutex<Instant>,
|
||||
/// Set in on_edge_selected when loop_restart approved; emitted+cleared in
|
||||
/// on_run_start.
|
||||
pub restarted_from: Arc<Mutex<Option<(String, String)>>>,
|
||||
pub restarted_from: Arc<Mutex<Option<(String, String)>>>,
|
||||
// Config for WorkflowRunStarted payload
|
||||
pub base_branch: Option<String>,
|
||||
pub base_sha: Option<String>,
|
||||
pub run_branch: Option<String>,
|
||||
pub worktree_dir: Option<String>,
|
||||
pub goal: Option<String>,
|
||||
pub captured_artifact_count: Arc<AtomicUsize>,
|
||||
// Cross-lifecycle data
|
||||
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
|
||||
pub last_git_sha: Arc<Mutex<Option<String>>>,
|
||||
pub final_patch: Arc<Mutex<Option<String>>>,
|
||||
pub circuit_breaker: Arc<CircuitBreakerLifecycle>,
|
||||
pub base_branch: Option<String>,
|
||||
pub base_sha: Option<String>,
|
||||
pub run_branch: Option<String>,
|
||||
pub worktree_dir: Option<String>,
|
||||
pub goal: Option<String>,
|
||||
/// Shared git checkpoint result (written by GitLifecycle, read by
|
||||
/// EventLifecycle when emitting CheckpointCompleted).
|
||||
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
|
||||
pub circuit_breaker: Arc<CircuitBreakerLifecycle>,
|
||||
}
|
||||
|
||||
fn snapshot_failure_signatures(
|
||||
|
|
@ -415,75 +411,4 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_run_end(&self, outcome: &Outcome, state: &WfRunState) {
|
||||
let duration_ms = crate::millis_u64(self.run_start.lock().unwrap().elapsed());
|
||||
let artifact_count = self.captured_artifact_count.load(Ordering::Relaxed);
|
||||
let last_sha = self.last_git_sha.lock().unwrap().clone();
|
||||
let final_patch = self.final_patch.lock().unwrap().clone();
|
||||
let run_billing_entries = state
|
||||
.node_outcomes
|
||||
.values()
|
||||
.filter_map(|o| o.usage.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let run_billing = (!run_billing_entries.is_empty())
|
||||
.then(|| BilledTokenCounts::from_billed_usage(&run_billing_entries));
|
||||
let total_usd_micros = run_billing
|
||||
.as_ref()
|
||||
.and_then(|billing| billing.total_usd_micros)
|
||||
.or_else(|| {
|
||||
let mut total = 0_i64;
|
||||
let mut has_total = false;
|
||||
for usage in state
|
||||
.node_outcomes
|
||||
.values()
|
||||
.filter_map(|o| o.usage.as_ref())
|
||||
{
|
||||
if let Some(value) = usage.total_usd_micros {
|
||||
total += value;
|
||||
has_total = true;
|
||||
}
|
||||
}
|
||||
has_total.then_some(total)
|
||||
});
|
||||
|
||||
if state.cancelled {
|
||||
self.emitter.emit(&Event::WorkflowRunFailed {
|
||||
error: Error::Cancelled,
|
||||
duration_ms,
|
||||
reason: FailureReason::Cancelled,
|
||||
git_commit_sha: last_sha,
|
||||
final_patch: final_patch.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if outcome.status == StageStatus::Success || outcome.status == StageStatus::PartialSuccess {
|
||||
self.emitter.emit(&Event::WorkflowRunCompleted {
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
status: outcome.status.to_string(),
|
||||
reason: match outcome.status {
|
||||
StageStatus::PartialSuccess => SuccessReason::PartialSuccess,
|
||||
_ => SuccessReason::Completed,
|
||||
},
|
||||
total_usd_micros,
|
||||
final_git_commit_sha: last_sha,
|
||||
final_patch,
|
||||
billing: run_billing,
|
||||
});
|
||||
} else {
|
||||
let error_msg = outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map_or_else(|| "run failed".to_string(), |f| f.message.clone());
|
||||
self.emitter.emit(&Event::WorkflowRunFailed {
|
||||
error: Error::engine(error_msg),
|
||||
duration_ms,
|
||||
reason: FailureReason::WorkflowError,
|
||||
git_commit_sha: last_sha,
|
||||
final_patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ use crate::event::{Emitter, Event, RunNoticeLevel};
|
|||
use crate::git::MetadataStore;
|
||||
use crate::graph::{WorkflowGraph, WorkflowNode};
|
||||
use crate::lifecycle::event::stage_scope_for;
|
||||
use crate::outcome::{BilledModelUsage, Outcome, StageStatus};
|
||||
use crate::outcome::BilledModelUsage;
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_git::{git_checkpoint, git_diff, git_diff_with_timeout, git_push_host};
|
||||
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
|
||||
|
||||
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||
|
|
@ -71,7 +71,6 @@ pub(crate) struct GitLifecycle {
|
|||
// Cross-lifecycle data (shared with EventLifecycle)
|
||||
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
|
||||
pub last_git_sha: Arc<Mutex<Option<String>>>,
|
||||
pub final_patch: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -80,7 +79,6 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
// Reset last_git_sha (diff base parity)
|
||||
*self.last_git_sha.lock().unwrap() = None;
|
||||
*self.checkpoint_git_result.lock().unwrap() = None;
|
||||
*self.final_patch.lock().unwrap() = None;
|
||||
|
||||
// Init metadata branch (best-effort)
|
||||
if let (Some(_), Some(repo_path)) = (
|
||||
|
|
@ -321,43 +319,4 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_run_end(&self, outcome: &Outcome, _state: &WfRunState) {
|
||||
// Capture the final diff for event/store projection.
|
||||
//
|
||||
// Success/PartialSuccess uses the standard 30 s timeout. Failed runs
|
||||
// use a shorter 10 s timeout: a pathological workspace (FS locks,
|
||||
// corrupted index) must not stall terminal event emission downstream
|
||||
// (Slack notifier, SSE RunFailed, CI hooks).
|
||||
if self.run_options.git.is_none() {
|
||||
return;
|
||||
}
|
||||
let timeout_ms = match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => 30_000,
|
||||
_ => 10_000,
|
||||
};
|
||||
if let Some(base_sha) = self
|
||||
.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.base_sha.clone())
|
||||
{
|
||||
match git_diff_with_timeout(&*self.sandbox, &base_sha, timeout_ms).await {
|
||||
Ok(patch) if !patch.is_empty() => {
|
||||
*self.final_patch.lock().unwrap() = Some(patch.clone());
|
||||
}
|
||||
Ok(_) => {
|
||||
*self.final_patch.lock().unwrap() = None;
|
||||
}
|
||||
Err(err) => {
|
||||
*self.final_patch.lock().unwrap() = None;
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "git_diff_failed".to_string(),
|
||||
message: format!("final diff failed: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ pub(crate) mod hook;
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -96,8 +96,6 @@ impl WorkflowLifecycle {
|
|||
let checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
let final_patch: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
let captured_artifact_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let circuit_breaker = Arc::new(CircuitBreakerLifecycle::new(loop_restart_signature_limit));
|
||||
|
||||
|
|
@ -114,21 +112,18 @@ impl WorkflowLifecycle {
|
|||
};
|
||||
|
||||
let event = EventLifecycle {
|
||||
emitter: Arc::clone(emitter),
|
||||
graph_name: graph.name.clone(),
|
||||
run_id: run_options.run_id,
|
||||
run_start: Mutex::new(Instant::now()),
|
||||
restarted_from: Arc::clone(&restarted_from),
|
||||
base_branch: run_options.base_branch.clone(),
|
||||
base_sha: run_options.git.as_ref().and_then(|g| g.base_sha.clone()),
|
||||
run_branch: run_options.git.as_ref().and_then(|g| g.run_branch.clone()),
|
||||
worktree_dir: working_directory.clone(),
|
||||
goal: (!graph.goal().is_empty()).then(|| graph.goal().to_string()),
|
||||
captured_artifact_count: Arc::clone(&captured_artifact_count),
|
||||
last_git_sha: Arc::clone(&last_git_sha),
|
||||
final_patch: Arc::clone(&final_patch),
|
||||
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
|
||||
circuit_breaker: Arc::clone(&circuit_breaker),
|
||||
emitter: Arc::clone(emitter),
|
||||
graph_name: graph.name.clone(),
|
||||
run_id: run_options.run_id,
|
||||
run_start: Mutex::new(Instant::now()),
|
||||
restarted_from: Arc::clone(&restarted_from),
|
||||
base_branch: run_options.base_branch.clone(),
|
||||
base_sha: run_options.git.as_ref().and_then(|g| g.base_sha.clone()),
|
||||
run_branch: run_options.git.as_ref().and_then(|g| g.run_branch.clone()),
|
||||
worktree_dir: working_directory.clone(),
|
||||
goal: (!graph.goal().is_empty()).then(|| graph.goal().to_string()),
|
||||
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
|
||||
circuit_breaker: Arc::clone(&circuit_breaker),
|
||||
};
|
||||
|
||||
let hook = HookLifecycle {
|
||||
|
|
@ -156,8 +151,7 @@ impl WorkflowLifecycle {
|
|||
run_options: Arc::clone(run_options),
|
||||
start_node_id,
|
||||
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
|
||||
last_git_sha: Arc::clone(&last_git_sha),
|
||||
final_patch,
|
||||
last_git_sha,
|
||||
};
|
||||
|
||||
let artifact = ArtifactLifecycle::new(
|
||||
|
|
@ -167,7 +161,6 @@ impl WorkflowLifecycle {
|
|||
run_options.run_id,
|
||||
run_options.artifact_globs(),
|
||||
artifact_sink,
|
||||
captured_artifact_count,
|
||||
);
|
||||
|
||||
Self {
|
||||
|
|
@ -419,12 +412,6 @@ impl RunLifecycle<WorkflowGraph> for WorkflowLifecycle {
|
|||
}
|
||||
|
||||
async fn on_run_end(&self, outcome: &Outcome, state: &WfRunState) {
|
||||
if state.cancelled {
|
||||
self.event.on_run_end(outcome, state).await;
|
||||
return;
|
||||
}
|
||||
self.git.on_run_end(outcome, state).await;
|
||||
self.event.on_run_end(outcome, state).await;
|
||||
self.hook.on_run_end(outcome, state).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ use crate::pipeline::initialize;
|
|||
use crate::pipeline::types::{InitOptions, LlmSpec, Persisted, SandboxEnvSpec};
|
||||
use crate::records::RunSpec;
|
||||
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
||||
use crate::run_status::{FailureReason, RunStatus};
|
||||
use crate::test_support::run_graph;
|
||||
|
||||
fn local_env() -> Arc<dyn Sandbox> {
|
||||
|
|
@ -789,10 +788,6 @@ async fn execute_cancelled_mid_run_persists_cancelled_status() {
|
|||
let executed = execute_test_run_with_options(run_options, g, Some(Arc::new(registry))).await;
|
||||
|
||||
assert!(matches!(executed.outcome, Err(Error::Cancelled)));
|
||||
let status = executed.run_store.state().await.unwrap().status.unwrap();
|
||||
assert_eq!(status, RunStatus::Failed {
|
||||
reason: FailureReason::Cancelled,
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_hooks::{HookContext, HookEvent, HookRunner};
|
||||
use fabro_types::BilledTokenCounts;
|
||||
use fabro_types::{BilledTokenCounts, EventBody};
|
||||
|
||||
use super::types::{Concluded, FinalizeOptions, Retroed};
|
||||
use crate::error::Error;
|
||||
|
|
@ -13,7 +13,7 @@ use crate::run_dump::RunDump;
|
|||
use crate::run_options::RunOptions;
|
||||
use crate::run_status::{FailureReason, RunStatus, SuccessReason};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_git::git_push_host;
|
||||
use crate::sandbox_git::{git_diff_with_timeout, git_push_host};
|
||||
|
||||
fn emit_run_notice(
|
||||
emitter: &Emitter,
|
||||
|
|
@ -101,12 +101,18 @@ fn build_conclusion_from_parts(
|
|||
run_duration_ms: u64,
|
||||
final_git_commit_sha: Option<String>,
|
||||
) -> Conclusion {
|
||||
let (stages, billing, total_retries) = if let Some(cp) = checkpoint {
|
||||
// Looping workflows revisit nodes; `completed_nodes` accumulates duplicates
|
||||
// while the other checkpoint maps are keyed by node_id. Dedupe to one row
|
||||
// per node so the stages table matches the deduped billing total.
|
||||
let (stages, total_retries) = if let Some(cp) = checkpoint {
|
||||
let mut stages = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut retries_sum: u32 = 0;
|
||||
let mut billed_usage = Vec::new();
|
||||
|
||||
for node_id in &cp.completed_nodes {
|
||||
if !seen.insert(node_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let outcome = cp.node_outcomes.get(node_id);
|
||||
let retries = cp
|
||||
.node_retries
|
||||
|
|
@ -116,10 +122,6 @@ fn build_conclusion_from_parts(
|
|||
.saturating_sub(1);
|
||||
retries_sum += retries;
|
||||
|
||||
if let Some(usage) = outcome.and_then(|o| o.usage.as_ref()) {
|
||||
billed_usage.push(usage.clone());
|
||||
}
|
||||
|
||||
stages.push(StageSummary {
|
||||
stage_id: node_id.clone(),
|
||||
stage_label: node_id.clone(),
|
||||
|
|
@ -130,13 +132,9 @@ fn build_conclusion_from_parts(
|
|||
retries,
|
||||
});
|
||||
}
|
||||
(
|
||||
stages,
|
||||
(!billed_usage.is_empty()).then(|| BilledTokenCounts::from_billed_usage(&billed_usage)),
|
||||
retries_sum,
|
||||
)
|
||||
(stages, retries_sum)
|
||||
} else {
|
||||
(vec![], None, 0)
|
||||
(vec![], 0)
|
||||
};
|
||||
|
||||
Conclusion {
|
||||
|
|
@ -146,16 +144,18 @@ fn build_conclusion_from_parts(
|
|||
failure_reason,
|
||||
final_git_commit_sha,
|
||||
stages,
|
||||
billing,
|
||||
billing: checkpoint.and_then(billing_from_checkpoint),
|
||||
total_retries,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a finalize projection snapshot commit to the metadata branch.
|
||||
///
|
||||
/// This captures the final `run.json` projection state, including conclusion
|
||||
/// and retro data. Best-effort: errors are logged as warnings.
|
||||
pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunStoreHandle) {
|
||||
/// `conclusion` is injected because the terminal event hasn't been emitted
|
||||
/// yet — the run store's `projection.conclusion` is still `None` at this point.
|
||||
pub async fn write_finalize_commit(
|
||||
run_options: &RunOptions,
|
||||
run_store: &RunStoreHandle,
|
||||
conclusion: &Conclusion,
|
||||
) {
|
||||
let (Some(meta_branch), Some(repo_path)) = (
|
||||
run_options
|
||||
.git
|
||||
|
|
@ -168,9 +168,12 @@ pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunStor
|
|||
|
||||
let git_author = run_options.git_author();
|
||||
let store = MetadataStore::new(repo_path, &git_author);
|
||||
let Ok(store_state) = run_store.state().await else {
|
||||
let Ok(mut store_state) = run_store.state().await else {
|
||||
return;
|
||||
};
|
||||
if store_state.conclusion.is_none() {
|
||||
store_state.conclusion = Some(conclusion.clone());
|
||||
}
|
||||
let dump = RunDump::from_projection(&store_state);
|
||||
if let Err(e) =
|
||||
dump.write_to_metadata_store(&store, &run_options.run_id.to_string(), "finalize run")
|
||||
|
|
@ -189,6 +192,101 @@ pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunStor
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Failed and cancelled runs use a shorter diff timeout so a corrupted
|
||||
/// workspace can't stall downstream consumers waiting on the terminal event.
|
||||
async fn compute_final_patch(
|
||||
run_options: &RunOptions,
|
||||
sandbox: &dyn fabro_agent::Sandbox,
|
||||
status: StageStatus,
|
||||
emitter: &Emitter,
|
||||
) -> Option<String> {
|
||||
let base_sha = run_options.git.as_ref().and_then(|g| g.base_sha.clone())?;
|
||||
let timeout_ms = match status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => 30_000,
|
||||
_ => 10_000,
|
||||
};
|
||||
match git_diff_with_timeout(sandbox, &base_sha, timeout_ms).await {
|
||||
Ok(patch) if !patch.is_empty() => Some(patch),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
emit_run_notice(
|
||||
emitter,
|
||||
RunNoticeLevel::Warn,
|
||||
"git_diff_failed",
|
||||
format!("final diff failed: {err}"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterates `node_outcomes.values()` rather than `completed_nodes` to avoid
|
||||
/// over-counting the last visit's usage on looping workflows.
|
||||
pub(crate) fn billing_from_checkpoint(cp: &Checkpoint) -> Option<BilledTokenCounts> {
|
||||
let usage: Vec<_> = cp
|
||||
.node_outcomes
|
||||
.values()
|
||||
.filter_map(|o| o.usage.clone())
|
||||
.collect();
|
||||
(!usage.is_empty()).then(|| BilledTokenCounts::from_billed_usage(&usage))
|
||||
}
|
||||
|
||||
pub(crate) fn build_terminal_event(
|
||||
outcome: &Result<Outcome, Error>,
|
||||
duration_ms: u64,
|
||||
artifact_count: usize,
|
||||
final_git_commit_sha: Option<String>,
|
||||
final_patch: Option<String>,
|
||||
billing: Option<BilledTokenCounts>,
|
||||
) -> Event {
|
||||
if matches!(outcome, Err(Error::Cancelled)) {
|
||||
return Event::WorkflowRunFailed {
|
||||
error: Error::Cancelled,
|
||||
duration_ms,
|
||||
reason: FailureReason::Cancelled,
|
||||
git_commit_sha: final_git_commit_sha,
|
||||
final_patch,
|
||||
};
|
||||
}
|
||||
|
||||
let outcome_status = outcome
|
||||
.as_ref()
|
||||
.map_or(StageStatus::Fail, |o| o.status.clone());
|
||||
|
||||
if outcome_status == StageStatus::Success || outcome_status == StageStatus::PartialSuccess {
|
||||
let total_usd_micros = billing.as_ref().and_then(|b| b.total_usd_micros);
|
||||
return Event::WorkflowRunCompleted {
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
status: outcome_status.to_string(),
|
||||
reason: match outcome_status {
|
||||
StageStatus::PartialSuccess => SuccessReason::PartialSuccess,
|
||||
_ => SuccessReason::Completed,
|
||||
},
|
||||
total_usd_micros,
|
||||
final_git_commit_sha,
|
||||
final_patch,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
let error = match outcome {
|
||||
Err(err) => err.clone(),
|
||||
Ok(o) => Error::engine(
|
||||
o.failure
|
||||
.as_ref()
|
||||
.map_or_else(|| "run failed".to_string(), |f| f.message.clone()),
|
||||
),
|
||||
};
|
||||
Event::WorkflowRunFailed {
|
||||
error,
|
||||
duration_ms,
|
||||
reason: FailureReason::WorkflowError,
|
||||
git_commit_sha: final_git_commit_sha,
|
||||
final_patch,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_hooks(
|
||||
hook_runner: Option<&HookRunner>,
|
||||
hook_context: &HookContext,
|
||||
|
|
@ -219,7 +317,11 @@ async fn cleanup_sandbox(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// FINALIZE phase: classify outcome, build conclusion, persist terminal state.
|
||||
/// FINALIZE phase: build conclusion, write the meta branch, emit the terminal
|
||||
/// `WorkflowRunCompleted`/`WorkflowRunFailed` event.
|
||||
///
|
||||
/// The terminal event is emitted here (not from `on_run_end`) so observers
|
||||
/// can't act on "done" before the meta branch writes are flushed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
|
@ -238,16 +340,41 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
|
|||
} = retroed;
|
||||
|
||||
let (final_status, failure_reason, _run_status) = classify_engine_result(&outcome);
|
||||
let conclusion = build_conclusion_from_store(
|
||||
&options.run_store,
|
||||
final_status,
|
||||
|
||||
let events = options.run_store.list_events().await.unwrap_or_default();
|
||||
let stage_durations = crate::extract_stage_durations_from_events(&events);
|
||||
let artifact_count = events
|
||||
.iter()
|
||||
.filter(|envelope| matches!(envelope.event.body, EventBody::ArtifactCaptured(_)))
|
||||
.count();
|
||||
let checkpoint = options
|
||||
.run_store
|
||||
.state()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|state| state.checkpoint);
|
||||
let conclusion = build_conclusion_from_parts(
|
||||
checkpoint.as_ref(),
|
||||
&stage_durations,
|
||||
final_status.clone(),
|
||||
failure_reason,
|
||||
duration_ms,
|
||||
options.last_git_sha.clone(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
write_finalize_commit(&run_options, &options.run_store).await;
|
||||
let final_patch = compute_final_patch(&run_options, &*sandbox, final_status, &emitter).await;
|
||||
|
||||
write_finalize_commit(&run_options, &options.run_store, &conclusion).await;
|
||||
|
||||
let terminal_event = build_terminal_event(
|
||||
&outcome,
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
options.last_git_sha.clone(),
|
||||
final_patch,
|
||||
conclusion.billing.clone(),
|
||||
);
|
||||
emitter.emit(&terminal_event);
|
||||
|
||||
if options.preserve_sandbox {
|
||||
let info = sandbox.sandbox_info();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ mod validate;
|
|||
|
||||
pub use execute::execute;
|
||||
pub use fabro_types::PullRequestRecord;
|
||||
pub(crate) use finalize::build_conclusion_from_store;
|
||||
pub(crate) use finalize::{
|
||||
billing_from_checkpoint, build_conclusion_from_store, build_terminal_event,
|
||||
};
|
||||
pub use finalize::{classify_engine_result, finalize, write_finalize_commit};
|
||||
pub use initialize::initialize;
|
||||
pub use parse::parse;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,40 @@ use crate::event::{Emitter, Event, StoreProgressLogger, append_event};
|
|||
use crate::handler::HandlerRegistry;
|
||||
use crate::outcome::Outcome;
|
||||
use crate::pipeline;
|
||||
use crate::pipeline::types::Initialized;
|
||||
use crate::pipeline::types::{Executed, Initialized};
|
||||
use crate::pipeline::{billing_from_checkpoint, build_terminal_event};
|
||||
use crate::records::Checkpoint;
|
||||
use crate::run_options::RunOptions;
|
||||
|
||||
/// These helpers stop at EXECUTE, so they emit the terminal event here to
|
||||
/// keep test consumers seeing the same end-of-run signal as production
|
||||
/// (FINALIZE).
|
||||
///
|
||||
/// The first flush is needed because `StoreProgressLogger` forwards events
|
||||
/// through an mpsc channel — without it, billing would read from a stale
|
||||
/// checkpoint. The second flush ensures the just-emitted terminal event is
|
||||
/// persisted before tests reopen the run store.
|
||||
async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
|
||||
let executed = Box::pin(pipeline::execute(initialized.initialized)).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let state = executed.run_store.state().await.ok();
|
||||
let billing = state
|
||||
.as_ref()
|
||||
.and_then(|s| s.checkpoint.as_ref())
|
||||
.and_then(billing_from_checkpoint);
|
||||
let event = build_terminal_event(
|
||||
&executed.outcome,
|
||||
executed.duration_ms,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
billing,
|
||||
);
|
||||
executed.emitter.emit(&event);
|
||||
initialized.store_logger.flush().await;
|
||||
executed
|
||||
}
|
||||
|
||||
pub fn test_store_dir(run_dir: &std::path::Path) -> PathBuf {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::process::id().hash(&mut hasher);
|
||||
|
|
@ -159,10 +189,7 @@ pub async fn run_graph(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
// Tests often reopen the run store immediately after `run()` returns.
|
||||
// Flush the async store logger first so they don't observe partial state.
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
executed.outcome
|
||||
}
|
||||
|
||||
|
|
@ -186,8 +213,7 @@ pub async fn run_graph_with_state(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
let outcome = executed.outcome?;
|
||||
let state = executed
|
||||
.run_store
|
||||
|
|
@ -219,8 +245,7 @@ pub async fn run_graph_with_hooks(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
executed.outcome
|
||||
}
|
||||
|
||||
|
|
@ -246,8 +271,7 @@ pub async fn run_graph_with_hooks_and_state(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
let outcome = executed.outcome?;
|
||||
let state = executed
|
||||
.run_store
|
||||
|
|
@ -278,8 +302,7 @@ pub async fn run_graph_from_checkpoint(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
executed.outcome
|
||||
}
|
||||
|
||||
|
|
@ -304,8 +327,7 @@ pub async fn run_graph_from_checkpoint_with_state(
|
|||
},
|
||||
)
|
||||
.await;
|
||||
let executed = pipeline::execute(initialized.initialized).await;
|
||||
initialized.store_logger.flush().await;
|
||||
let executed = execute_and_emit_terminal(initialized).await;
|
||||
let outcome = executed.outcome?;
|
||||
let state = executed
|
||||
.run_store
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::process::Stdio;
|
|||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tokio::time::{Duration, timeout};
|
||||
use twin_openai::config::Config;
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -324,34 +324,26 @@ async fn debug_page_renders_in_headless_chrome() {
|
|||
.arg(format!("{}/__debug?refresh=0", server.base_url))
|
||||
.spawn()
|
||||
.expect("Chrome should start");
|
||||
let mut screenshot_data = None;
|
||||
for _ in 0..200 {
|
||||
if let Ok(data) = std::fs::read(&screenshot_path) {
|
||||
if data.len() >= 10_000 {
|
||||
screenshot_data = Some(data);
|
||||
break;
|
||||
}
|
||||
|
||||
// Chrome with --screenshot exits once the file is written, so waiting on
|
||||
// the process is the deterministic completion signal.
|
||||
let wait_result = timeout(Duration::from_mins(2), child.wait()).await;
|
||||
let status = match wait_result {
|
||||
Ok(Ok(status)) => status,
|
||||
Ok(Err(err)) => panic!("Chrome wait failed: {err}"),
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
panic!("Chrome did not exit within 120s while taking screenshot");
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
status.success(),
|
||||
"Chrome exited with non-success status: {status}"
|
||||
);
|
||||
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.expect("Chrome status check should succeed")
|
||||
{
|
||||
panic!("Chrome exited before writing screenshot, status: {status}");
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let screenshot_data = screenshot_data.expect("Chrome should write a screenshot within 20s");
|
||||
if child
|
||||
.try_wait()
|
||||
.expect("Chrome status check should succeed")
|
||||
.is_none()
|
||||
{
|
||||
child.start_kill().expect("Chrome should be killable");
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
let screenshot_data = std::fs::read(&screenshot_path)
|
||||
.expect("Chrome exited successfully but screenshot file is missing");
|
||||
|
||||
assert!(
|
||||
screenshot_data.len() >= 10_000,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue